-
- {/* 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 && (
+
+ {completeness.incompleteSubmissionCount} of{' '}
+ {completeness.totalShortlisted} shortlisted submissions are
+ missing scores from one or more active judges.
+
+
+
+ {/* 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 */}
+
+ {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
+
+ )}
+
+
+ {/* 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. */}
+
+
+
+ {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.`}
+
+ )}
+
+ );
+}
diff --git a/components/auth/SignupForm.tsx b/components/auth/SignupForm.tsx
index adc9d64de..7c7c4c620 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 6f1eeed8a..001f8def5 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 000000000..273bc1eb0
--- /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 (
+
- Show all projects (accepted, shortlisted, and
- rejected).
+ Submissions stay hidden until you shortlist them.
+ Disqualified projects are never shown.
@@ -196,17 +201,33 @@ export default function SubmissionVisibilitySettingsTab({
- Accepted/Shortlisted Only
+ Hidden until results are announced
+
+
+ Submissions stay hidden from everyone (except the
+ participants who made them and your team) until you
+ publish results. Then only shortlisted projects
+ appear.
+
+
+
+
+
+
+
+
All submissions
- Only show projects that have been approved or
- shortlisted.
+ Every submission is visible as soon as it's
+ made. Disqualified projects are still hidden.
diff --git a/content/blog/boundless-launches-first-hackathon-campaign.mdx b/content/blog/boundless-launches-first-hackathon-campaign.mdx
new file mode 100644
index 000000000..4e17d99bc
--- /dev/null
+++ b/content/blog/boundless-launches-first-hackathon-campaign.mdx
@@ -0,0 +1,161 @@
+---
+title: 'Boundless Launches Its First Hackathon Campaign'
+excerpt: 'Boundless x Trustless Work, our first-ever hackathon, runs from May 13 to May 16, 2026. Four days of building escrow-powered products on Stellar, with workshops, mentorship, a $1,400 prize pool, and a physical finale in Nsukka, Nigeria.'
+coverImage: 'https://res.cloudinary.com/danuy5rqb/image/upload/v1778798556/Image_2_sueerh.png'
+publishedAt: '2026-05-13'
+author:
+ name: 'Boundless Team'
+ image: ''
+categories: ['Hackathon', 'Web3', 'Stellar']
+tags:
+ [
+ 'hackathon',
+ 'trustless-work',
+ 'stellar',
+ 'web3',
+ 'defi',
+ 'escrow',
+ 'builders',
+ ]
+readingTime: 5
+isFeatured: true
+seoTitle: 'Boundless Launches Its First Hackathon: Boundless x Trustless Work'
+seoDescription: 'The Boundless x Trustless Work Hackathon kicks off May 13 to May 16, 2026. Build escrow-powered Web3 products on Stellar with the Trustless Work Starter Kit. $1,400 prize pool, workshops, and a physical finale in Nsukka, Nigeria.'
+---
+
+# Boundless Launches Its First Hackathon Campaign
+
+Boundless is officially launching its first-ever hackathon today, **May 13th, 2026**.
+
+In collaboration with **[Trustless Work](https://www.trustlesswork.com/)**, we are bringing together builders, creatives, developers, and thinkers for a multi-day hybrid event focused on brainstorming and building ideas that will revolutionize Web3 and DeFi.
+
+The Boundless x [Trustless Work](https://www.trustlesswork.com/) Hackathon is designed to encourage experimentation, collaboration, and real-world problem-solving. Participants will have the opportunity to ideate and build projects on the **Stellar** ecosystem.
+
+---
+
+## Why Boundless
+
+The next wave of the internet is being built in Web3.
+
+Every day, new ideas emerge across payments, coordination, finance, and digital ownership. But for many builders, the gap between ideation and execution still remains difficult to cross.
+
+At Boundless, our mission is to help bridge that gap.
+
+We believe the next generation of Web3 products should be built on systems that prioritize **transparency, verification, efficiency, and seamless global collaboration** from the start.
+
+The Boundless x [Trustless Work](https://www.trustlesswork.com/) Hackathon is designed to help developers explore what that future could look like in practice.
+
+---
+
+## Build Faster with the [Trustless Work](https://www.trustlesswork.com/) Starter Kit
+
+The focus of this hack is to encourage innovative thinking and unique real-world problem-solving techniques. That is why we are partnering with **[Trustless Work](https://www.trustlesswork.com/)**, so teams can focus on:
+
+- Product design
+- Workflow architecture
+- User experience
+- Integrations
+- Application-level innovation
+
+[Trustless Work](https://www.trustlesswork.com/) allows teams to implement escrow systems from start to finish in a very short time. This is also why we have limited use cases to products that require escrow as the core infrastructure.
+
+To help participants move quickly from ideation to implementation, developers will have access to the **[Trustless Work Builder Starter Kit](https://www.trustlesswork.com/)**.
+
+The hackathon will also include workshops, mentorship sessions, and technical support from the team at [Trustless Work](https://www.trustlesswork.com/) to help participants navigate implementation challenges and refine their projects.
+
+---
+
+## Event Details
+
+The hackathon is a **hybrid event** taking place from **May 13th to May 16th**. The first three days will be held virtually, while the final day will be a physical event.
+
+Here is the breakdown of what to expect each day:
+
+- **May 13:** Welcome Ceremony, Onboarding, and Team Selection
+- **May 14:** Build Day Two
+- **May 15:** Build Day Three
+- **May 16:** Project Demos, Judging, and Awards
+
+Activities throughout the hackathon will include:
+
+- Workshops
+- Office hours
+
+The physical event will include:
+
+- Project showcases
+- Technical presentations
+- Judging sessions
+- Winner announcements
+- Award presentations
+
+**Other Details**
+
+- **Venue:** Block Hive, Nsukka, Enugu State, Nigeria
+- **Registration Deadline:** May 15th
+
+---
+
+## Prize Pool
+
+The Boundless x [Trustless Work](https://www.trustlesswork.com/) Hackathon will reward outstanding technical execution, product thinking, and real-world usability across categories signifying our judging criteria.
+
+**Main Winners**
+
+- 1st Place: **$500**
+- 2nd Place: **$300**
+- 3rd Place: **$200**
+
+**Special Awards**
+
+- Best UI/UX Design: **$100**
+- Best Real-World Use Case: **$100**
+- Best Technical Implementation: **$100**
+- Most Innovative Idea: **$100**
+- Best Use of [Trustless Work](https://www.trustlesswork.com/): **$100**
+
+---
+
+## Who Can Participate?
+
+The hackathon is open to **developers, designers, product builders**, and anyone interested in building internet-native coordination systems.
+
+Participants may hack alone or form teams of **2 to 5 members** across the participant scope. Participants do not need to arrive with a fully developed idea.
+
+---
+
+## What Should You Build?
+
+Participants are expected to build a focused product using [Trustless Work](https://www.trustlesswork.com/) primitives. The goal is not to build another generic escrow platform, but to create specific applications that solve real problems.
+
+Some possible directions include:
+
+- Freelance and milestone-based work platforms
+- Marketplace and e-commerce escrow systems
+- Grant distribution and bounty management tools
+- Crowdfunding and pre-order infrastructure
+- Rental agreements and security deposit flows
+- P2P stablecoin exchange applications
+- Trade finance coordination tools
+- AI agent payment and workflow systems
+
+Participants will also have flexible integration options depending on their preferred workflow. Teams can move quickly with low-code tooling, build with prebuilt components, or work directly with **SDKs and APIs** for more advanced customization and deeper product control.
+
+---
+
+## Join Us
+
+To build with us, register your intent: **[Register for the Hackathon](https://www.boundlessfi.xyz/hackathons/boundless-trustless-work-hackathon)**.
+
+Join our community to stay in the loop:
+
+- **[Discord](https://discord.gg/fP8chNpX)**
+- **[X (formerly Twitter)](https://x.com/boundless_fi)**
+
+---
+
+## Starter Pack
+
+- Explore the **[Trustless Work Starter Kit](https://www.trustlesswork.com/)**
+
+Build the future with Boundless.
diff --git a/hooks/hackathon/use-submission.ts b/hooks/hackathon/use-submission.ts
index 87182df1b..9014d6ec6 100644
--- a/hooks/hackathon/use-submission.ts
+++ b/hooks/hackathon/use-submission.ts
@@ -134,7 +134,10 @@ export function useSubmission({
'Failed to create submission'
);
setError(errorMessage);
- toast.error(errorMessage);
+ toast.error('Submission failed', {
+ description: errorMessage,
+ duration: 8000,
+ });
reportError(err, {
context: 'hackathon-createSubmission',
hackathonSlugOrId,
@@ -180,7 +183,10 @@ export function useSubmission({
'Failed to update submission'
);
setError(errorMessage);
- toast.error(errorMessage);
+ toast.error('Update failed', {
+ description: errorMessage,
+ duration: 8000,
+ });
reportError(err, {
context: 'hackathon-updateSubmission',
submissionId,
@@ -214,7 +220,10 @@ export function useSubmission({
'Failed to delete submission'
);
setError(errorMessage);
- toast.error(errorMessage);
+ toast.error('Delete failed', {
+ description: errorMessage,
+ duration: 8000,
+ });
throw err;
} finally {
setIsSubmitting(false);
diff --git a/hooks/judge/use-judge-queries.ts b/hooks/judge/use-judge-queries.ts
new file mode 100644
index 000000000..a0e419ba4
--- /dev/null
+++ b/hooks/judge/use-judge-queries.ts
@@ -0,0 +1,236 @@
+'use client';
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner';
+import {
+ acceptJudgeInvitation,
+ declineJudgeInvitation,
+ getJudgeCriteria,
+ getJudgeHackathon,
+ getJudgeHackathons,
+ getJudgeInvitations,
+ getJudgeQueueNeighbors,
+ getJudgeResults,
+ getJudgeSubmission,
+ getJudgeSubmissions,
+ previewJudgeInvitation,
+ submitJudgeScore,
+ type SubmitJudgeScoreRequest,
+} from '@/lib/api/judge';
+import { extractApiErrorMessage } from '@/lib/api/api';
+
+export const judgeKeys = {
+ all: ['judge'] as const,
+ hackathons: () => [...judgeKeys.all, 'hackathons'] as const,
+ hackathon: (id: string) => [...judgeKeys.all, 'hackathon', id] as const,
+ criteria: (id: string) => [...judgeKeys.all, 'criteria', id] as const,
+ submissions: (id: string, params?: Record) =>
+ [...judgeKeys.all, 'submissions', id, params ?? {}] as const,
+ submission: (hackathonId: string, submissionId: string) =>
+ [...judgeKeys.all, 'submission', hackathonId, submissionId] as const,
+ neighbors: (hackathonId: string, submissionId: string) =>
+ [...judgeKeys.all, 'neighbors', hackathonId, submissionId] as const,
+ results: (hackathonId: string) =>
+ [...judgeKeys.all, 'results', hackathonId] as const,
+ invitations: () => [...judgeKeys.all, 'invitations'] as const,
+ invitation: (token: string) =>
+ [...judgeKeys.all, 'invitation', token] as const,
+};
+
+export function useJudgeHackathons() {
+ return useQuery({
+ queryKey: judgeKeys.hackathons(),
+ queryFn: async () => {
+ const res = await getJudgeHackathons();
+ if (!res.success) throw new Error(res.message);
+ return res.data ?? [];
+ },
+ });
+}
+
+export function useJudgeHackathon(hackathonId: string | null | undefined) {
+ return useQuery({
+ queryKey: judgeKeys.hackathon(hackathonId ?? ''),
+ queryFn: async () => {
+ const res = await getJudgeHackathon(hackathonId as string);
+ if (!res.success) throw new Error(res.message);
+ return res.data;
+ },
+ enabled: !!hackathonId,
+ });
+}
+
+export function useJudgeCriteria(hackathonId: string | null | undefined) {
+ return useQuery({
+ queryKey: judgeKeys.criteria(hackathonId ?? ''),
+ queryFn: async () => {
+ const res = await getJudgeCriteria(hackathonId as string);
+ if (!res.success) throw new Error(res.message);
+ return res.data ?? [];
+ },
+ enabled: !!hackathonId,
+ });
+}
+
+export function useJudgeSubmissions(
+ hackathonId: string | null | undefined,
+ params: { page?: number; limit?: number; search?: string } = {}
+) {
+ return useQuery({
+ queryKey: judgeKeys.submissions(hackathonId ?? '', params),
+ queryFn: async () => {
+ const res = await getJudgeSubmissions(hackathonId as string, params);
+ if (!res.success) throw new Error(res.message);
+ return res.data;
+ },
+ enabled: !!hackathonId,
+ });
+}
+
+export function useJudgeResults(hackathonId: string | null | undefined) {
+ return useQuery({
+ queryKey: judgeKeys.results(hackathonId ?? ''),
+ queryFn: async () => {
+ const res = await getJudgeResults(hackathonId as string);
+ if (!res.success) throw new Error(res.message);
+ return res.data;
+ },
+ enabled: !!hackathonId,
+ });
+}
+
+export function useJudgeSubmission(
+ hackathonId: string | null | undefined,
+ submissionId: string | null | undefined
+) {
+ return useQuery({
+ queryKey: judgeKeys.submission(hackathonId ?? '', submissionId ?? ''),
+ queryFn: async () => {
+ const res = await getJudgeSubmission(
+ hackathonId as string,
+ submissionId as string
+ );
+ if (!res.success) throw new Error(res.message);
+ return res.data;
+ },
+ enabled: !!hackathonId && !!submissionId,
+ });
+}
+
+export function useJudgeQueueNeighbors(
+ hackathonId: string | null | undefined,
+ submissionId: string | null | undefined
+) {
+ return useQuery({
+ queryKey: judgeKeys.neighbors(hackathonId ?? '', submissionId ?? ''),
+ queryFn: async () => {
+ const res = await getJudgeQueueNeighbors(
+ hackathonId as string,
+ submissionId as string
+ );
+ if (!res.success) throw new Error(res.message);
+ return res.data;
+ },
+ enabled: !!hackathonId && !!submissionId,
+ });
+}
+
+export function useSubmitJudgeScore(hackathonId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (args: {
+ submissionId: string;
+ payload: SubmitJudgeScoreRequest;
+ }) => {
+ const res = await submitJudgeScore(
+ hackathonId,
+ args.submissionId,
+ args.payload
+ );
+ if (!res.success) throw new Error(res.message);
+ return res.data;
+ },
+ onSuccess: (_data, args) => {
+ toast.success('Score submitted');
+ qc.invalidateQueries({ queryKey: judgeKeys.hackathon(hackathonId) });
+ qc.invalidateQueries({
+ queryKey: judgeKeys.submissions(hackathonId),
+ exact: false,
+ });
+ qc.invalidateQueries({
+ queryKey: judgeKeys.submission(hackathonId, args.submissionId),
+ });
+ qc.invalidateQueries({
+ queryKey: judgeKeys.neighbors(hackathonId, args.submissionId),
+ });
+ },
+ onError: err => {
+ toast.error(extractApiErrorMessage(err, 'Failed to submit score'));
+ },
+ });
+}
+
+export function useMyJudgeInvitations() {
+ return useQuery({
+ queryKey: judgeKeys.invitations(),
+ queryFn: async () => {
+ const res = await getJudgeInvitations();
+ if (!res.success) throw new Error(res.message);
+ return res.data ?? [];
+ },
+ });
+}
+
+/**
+ * Public preview — works when logged out, so the invitee can see
+ * what they're being invited to before signing in.
+ */
+export function useJudgeInvitationPreview(token: string | undefined) {
+ return useQuery({
+ queryKey: judgeKeys.invitation(token ?? ''),
+ queryFn: async () => {
+ const res = await previewJudgeInvitation(token as string);
+ if (!res.success) throw new Error(res.message);
+ return res.data;
+ },
+ enabled: !!token,
+ retry: false,
+ });
+}
+
+export function useAcceptJudgeInvitation(token: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (payload: { displayName?: string } = {}) => {
+ const res = await acceptJudgeInvitation(token, payload);
+ if (!res.success) throw new Error(res.message);
+ return res.data;
+ },
+ onSuccess: () => {
+ toast.success('Invitation accepted — you’re on the panel.');
+ qc.invalidateQueries({ queryKey: judgeKeys.all });
+ },
+ onError: err => {
+ toast.error(extractApiErrorMessage(err, 'Could not accept invitation'));
+ },
+ });
+}
+
+export function useDeclineJudgeInvitation(token: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async () => {
+ const res = await declineJudgeInvitation(token);
+ if (!res.success) throw new Error(res.message);
+ return res.data;
+ },
+ onSuccess: () => {
+ toast.success('Invitation declined.');
+ qc.invalidateQueries({ queryKey: judgeKeys.invitations() });
+ qc.invalidateQueries({ queryKey: judgeKeys.invitation(token) });
+ },
+ onError: err => {
+ toast.error(extractApiErrorMessage(err, 'Could not decline invitation'));
+ },
+ });
+}
diff --git a/hooks/judge/use-organizer-invitations.ts b/hooks/judge/use-organizer-invitations.ts
new file mode 100644
index 000000000..c5fab36f6
--- /dev/null
+++ b/hooks/judge/use-organizer-invitations.ts
@@ -0,0 +1,126 @@
+'use client';
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner';
+import {
+ cancelJudgeInvitation,
+ inviteJudge,
+ listJudgeInvitations,
+ resendJudgeInvitation,
+ type InviteJudgePayload,
+ type JudgeInvitationStatus,
+} from '@/lib/api/judge';
+import { extractApiErrorMessage } from '@/lib/api/api';
+
+export const orgJudgeKeys = {
+ all: ['org-judge'] as const,
+ invitations: (
+ orgId: string,
+ hackathonId: string,
+ status?: JudgeInvitationStatus
+ ) =>
+ [
+ ...orgJudgeKeys.all,
+ 'invitations',
+ orgId,
+ hackathonId,
+ status ?? 'all',
+ ] as const,
+};
+
+export function useOrgJudgeInvitations(
+ organizationId: string | null | undefined,
+ hackathonId: string | null | undefined,
+ status?: JudgeInvitationStatus
+) {
+ return useQuery({
+ queryKey: orgJudgeKeys.invitations(
+ organizationId ?? '',
+ hackathonId ?? '',
+ status
+ ),
+ queryFn: async () => {
+ const res = await listJudgeInvitations(
+ organizationId as string,
+ hackathonId as string,
+ status
+ );
+ if (!res.success) throw new Error(res.message);
+ return res.data ?? [];
+ },
+ enabled: !!organizationId && !!hackathonId,
+ // Organizers actively monitor the panel — refresh every minute so
+ // accepted/declined invitations surface without a manual reload.
+ refetchInterval: 60_000,
+ refetchOnWindowFocus: true,
+ refetchOnMount: 'always',
+ });
+}
+
+export function useInviteJudge(organizationId: string, hackathonId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (payload: InviteJudgePayload) => {
+ const res = await inviteJudge(organizationId, hackathonId, payload);
+ if (!res.success) throw new Error(res.message);
+ return res.data;
+ },
+ onSuccess: () => {
+ toast.success('Invitation sent');
+ qc.invalidateQueries({ queryKey: orgJudgeKeys.all });
+ },
+ onError: err => {
+ toast.error(extractApiErrorMessage(err, 'Could not send invitation'));
+ },
+ });
+}
+
+export function useResendInvitation(
+ organizationId: string,
+ hackathonId: string
+) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (invitationId: string) => {
+ const res = await resendJudgeInvitation(
+ organizationId,
+ hackathonId,
+ invitationId
+ );
+ if (!res.success) throw new Error(res.message);
+ return res.data;
+ },
+ onSuccess: () => {
+ toast.success('Invitation resent with a fresh link');
+ qc.invalidateQueries({ queryKey: orgJudgeKeys.all });
+ },
+ onError: err => {
+ toast.error(extractApiErrorMessage(err, 'Could not resend invitation'));
+ },
+ });
+}
+
+export function useCancelInvitation(
+ organizationId: string,
+ hackathonId: string
+) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (invitationId: string) => {
+ const res = await cancelJudgeInvitation(
+ organizationId,
+ hackathonId,
+ invitationId
+ );
+ if (!res.success) throw new Error(res.message);
+ return res.data;
+ },
+ onSuccess: () => {
+ toast.success('Invitation cancelled');
+ qc.invalidateQueries({ queryKey: orgJudgeKeys.all });
+ },
+ onError: err => {
+ toast.error(extractApiErrorMessage(err, 'Could not cancel invitation'));
+ },
+ });
+}
diff --git a/lib/api/hackathons.ts b/lib/api/hackathons.ts
index 63cc11251..2a998257d 100644
--- a/lib/api/hackathons.ts
+++ b/lib/api/hackathons.ts
@@ -32,6 +32,7 @@ export enum SubmissionVisibility {
export enum SubmissionStatusVisibility {
ALL = 'ALL',
ACCEPTED_SHORTLISTED = 'ACCEPTED_SHORTLISTED',
+ HIDDEN_UNTIL_RESULTS = 'HIDDEN_UNTIL_RESULTS',
}
export enum VenueType {
@@ -125,8 +126,13 @@ export interface HackathonRewards {
}
// Judging Tab Types
+//
+// Persisted criteria always carry a non-empty unique `id` (the server
+// auto-generates one on create). In-progress local drafts may not yet
+// have an id, but anything coming back from the API does. Consumers
+// should rely on `id` and never fall back to `name`/`title`.
export interface JudgingCriterion {
- id?: string;
+ id: string;
name?: string;
title: string;
weight: number; // 0-100
@@ -702,6 +708,7 @@ export interface ParticipantSubmission {
category: string;
description: string;
logo?: string;
+ banner?: string;
videoUrl?: string;
introduction?: string;
links?: Array<{ type: string; url: string }>;
diff --git a/lib/api/hackathons/judging.ts b/lib/api/hackathons/judging.ts
index 800a03324..f2c9ec8ad 100644
--- a/lib/api/hackathons/judging.ts
+++ b/lib/api/hackathons/judging.ts
@@ -3,7 +3,11 @@ import { ApiResponse, PaginatedResponse } from '../types';
// Judging API Types
export interface JudgingCriterion {
- id?: string;
+ // Server guarantees a non-empty unique id (auto-generated on create
+ // if the client omits one). Frontend code should rely on this and
+ // not fall back to name/title — the fallback chain silently merges
+ // duplicate-named criteria.
+ id: string;
name?: string;
title: string;
weight: number; // 0-100
@@ -623,14 +627,47 @@ export const getJudgingWinners = async (
};
/**
- * Publish judging results (finalize rankings)
+ * Judging completeness preview: what would block publishing right now.
*/
-export const publishJudgingResults = async (
+export interface JudgingCompletenessPreview {
+ complete: boolean;
+ expectedJudgeCount: number;
+ totalShortlisted: number;
+ incompleteSubmissionCount: number;
+ incompleteJudges: Array<{
+ id: string;
+ name: string;
+ missingCount: number;
+ }>;
+ sampleIncompleteSubmissions: Array<{
+ submissionId: string;
+ projectName: string;
+ missingJudges: Array<{ id: string; name: string }>;
+ }>;
+}
+
+export const getJudgingCompleteness = async (
organizationId: string,
hackathonId: string
+): Promise> => {
+ const res = await api.get(
+ `/organizations/${organizationId}/hackathons/${hackathonId}/judging/completeness`
+ );
+ return res.data;
+};
+
+/**
+ * Publish judging results (finalize rankings). Pass `acceptPartial: true`
+ * to publish even when some active judges have outstanding work.
+ */
+export const publishJudgingResults = async (
+ organizationId: string,
+ hackathonId: string,
+ options: { acceptPartial?: boolean } = {}
): Promise> => {
const res = await api.post(
- `/organizations/${organizationId}/hackathons/${hackathonId}/judging/publish-results`
+ `/organizations/${organizationId}/hackathons/${hackathonId}/judging/publish-results`,
+ options.acceptPartial ? { acceptPartial: true } : undefined
);
return res.data;
};
diff --git a/lib/api/judge.ts b/lib/api/judge.ts
new file mode 100644
index 000000000..f55d9de15
--- /dev/null
+++ b/lib/api/judge.ts
@@ -0,0 +1,408 @@
+import api from './api';
+import { ApiResponse } from './types';
+import type {
+ CriterionScoreRequest,
+ JudgingCriterion,
+ JudgingSubmission,
+} from './hackathons/judging';
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export type JudgeInvitationStatus =
+ | 'PENDING'
+ | 'ACCEPTED'
+ | 'DECLINED'
+ | 'REVOKED'
+ | 'EXPIRED';
+
+export interface JudgeHackathonSummary {
+ id: string;
+ slug: string | null;
+ name: string;
+ banner: string | null;
+ status: string;
+ judgingStart: string | null;
+ judgingEnd: string | null;
+ submissionDeadline: string | null;
+ resultsPublished: boolean;
+ resultsPublishedAt: string | null;
+ organization: { id: string; name: string; logo: string | null } | null;
+ totalSubmissions: number;
+ myScoredCount: number;
+}
+
+export interface JudgeAssignment {
+ assignedAt: string;
+ hackathon: JudgeHackathonSummary;
+}
+
+export interface JudgeHackathonOverview {
+ id: string;
+ slug: string | null;
+ name: string;
+ banner: string | null;
+ description: string | null;
+ tagline: string | null;
+ status: string;
+ judgingStart: string | null;
+ judgingEnd: string | null;
+ submissionDeadline: string | null;
+ judgingCriteria: JudgingCriterion[] | null;
+ resultsPublished: boolean;
+ resultsPublishedAt: string | null;
+ organization: { id: string; name: string; logo: string | null } | null;
+ totalSubmissions: number;
+ myScoredCount: number;
+ /** First unscored submission in the canonical queue, or null when caught up. */
+ firstUnscoredSubmissionId: string | null;
+}
+
+export interface JudgeQueueNeighbor {
+ submissionId: string;
+ projectName: string;
+}
+
+export interface JudgeQueueNeighbors {
+ position: number | null;
+ total: number;
+ scoredCount: number;
+ prev: JudgeQueueNeighbor | null;
+ next: JudgeQueueNeighbor | null;
+ /** Next unscored after the current submission. Wraps to the start of the queue if none found after. */
+ nextUnscored: JudgeQueueNeighbor | null;
+}
+
+export interface JudgeSubmissionsResponse {
+ submissions: JudgingSubmission[];
+ criteria: JudgingCriterion[];
+ pagination: {
+ page: number;
+ limit: number;
+ total: number;
+ totalPages: number;
+ };
+ /** Total submissions the calling judge has scored in this hackathon. */
+ scoredCount?: number;
+}
+
+export interface JudgeInvitationPreview {
+ id: string;
+ email: string;
+ status: JudgeInvitationStatus;
+ expiresAt: string;
+ displayName: string | null;
+ message: string | null;
+ isExpired: boolean;
+ hackathon: {
+ id: string;
+ slug: string | null;
+ name: string;
+ banner: string | null;
+ judgingStart: string | null;
+ judgingEnd: string | null;
+ organization: { id: string; name: string; logo: string | null } | null;
+ };
+ invitedBy: { id: string; name: string; image: string | null } | null;
+}
+
+export interface JudgeInvitationSummary {
+ id: string;
+ hackathonId: string;
+ email: string;
+ token: string;
+ status: JudgeInvitationStatus;
+ invitedAt: string;
+ expiresAt: string;
+ message: string | null;
+ displayName: string | null;
+ hackathon: JudgeInvitationPreview['hackathon'];
+ invitedBy: { id: string; name: string } | null;
+}
+
+export interface SubmitJudgeScoreRequest {
+ criteriaScores: CriterionScoreRequest[];
+ comment?: string;
+ notes?: string;
+}
+
+// ---------------------------------------------------------------------------
+// Endpoints — judge dashboard
+// ---------------------------------------------------------------------------
+
+export const getJudgeHackathons = async (): Promise<
+ ApiResponse
+> => {
+ const res =
+ await api.get>('/judge/hackathons');
+ return res.data;
+};
+
+export const getJudgeHackathon = async (
+ hackathonId: string
+): Promise> => {
+ const res = await api.get>(
+ `/judge/hackathons/${hackathonId}`
+ );
+ return res.data;
+};
+
+export const getJudgeCriteria = async (
+ hackathonId: string
+): Promise> => {
+ const res = await api.get>(
+ `/judge/hackathons/${hackathonId}/criteria`
+ );
+ return res.data;
+};
+
+export const getJudgeSubmissions = async (
+ hackathonId: string,
+ params: {
+ page?: number;
+ limit?: number;
+ search?: string;
+ sortBy?: 'date' | 'name' | 'score' | 'rank';
+ order?: 'asc' | 'desc';
+ } = {}
+): Promise> => {
+ const search = new URLSearchParams();
+ if (params.page) search.set('page', String(params.page));
+ if (params.limit) search.set('limit', String(params.limit));
+ if (params.search) search.set('search', params.search);
+ if (params.sortBy) search.set('sortBy', params.sortBy);
+ if (params.order) search.set('order', params.order);
+ const qs = search.toString();
+ const res = await api.get>(
+ `/judge/hackathons/${hackathonId}/submissions${qs ? `?${qs}` : ''}`
+ );
+ return res.data;
+};
+
+export interface JudgeResultItem {
+ submissionId: string;
+ projectName: string;
+ participantId: string;
+ teamId: string | null;
+ status: string;
+ submittedAt: string;
+ averageScore: number;
+ totalScore: number;
+ judgeCount: number;
+ expectedJudgeCount: number;
+ rank: number | null;
+ computedRank?: number;
+ isComplete: boolean;
+ prize?: string;
+}
+
+export interface JudgeResultsResponse {
+ resultsPublished: boolean;
+ resultsPublishedAt: string | null;
+ results: JudgeResultItem[];
+}
+
+export const getJudgeResults = async (
+ hackathonId: string
+): Promise> => {
+ const res = await api.get>(
+ `/judge/hackathons/${hackathonId}/results`
+ );
+ return res.data;
+};
+
+export interface JudgeSubmissionDetail {
+ submission: {
+ id: string;
+ projectName: string;
+ category: string;
+ description: string;
+ logo?: string;
+ videoUrl?: string;
+ introduction?: string;
+ links: Array<{ type?: string; url?: string }>;
+ socialLinks: Record;
+ submissionDate: string;
+ status: string;
+ rank?: number;
+ };
+ participant: {
+ id: string;
+ userId: string;
+ user: {
+ id: string;
+ email: string;
+ name: string;
+ username: string;
+ image?: string;
+ };
+ participationType: string;
+ teamId?: string;
+ teamName?: string;
+ };
+ myScore: {
+ judgeId: string;
+ judgeName: string;
+ criteriaScores: Array<{
+ criterionId: string;
+ score: number;
+ comment?: string;
+ }>;
+ totalScore: number;
+ submittedAt: string;
+ comment: string | null;
+ } | null;
+ criteria: JudgingCriterion[];
+ averageScore: number | null;
+ judgeCount: number;
+ resultsPublished: boolean;
+}
+
+export const getJudgeSubmission = async (
+ hackathonId: string,
+ submissionId: string
+): Promise> => {
+ const res = await api.get>(
+ `/judge/hackathons/${hackathonId}/submissions/${submissionId}`
+ );
+ return res.data;
+};
+
+export const getJudgeQueueNeighbors = async (
+ hackathonId: string,
+ submissionId: string
+): Promise> => {
+ const res = await api.get>(
+ `/judge/hackathons/${hackathonId}/submissions/${submissionId}/neighbors`
+ );
+ return res.data;
+};
+
+export const submitJudgeScore = async (
+ hackathonId: string,
+ submissionId: string,
+ payload: SubmitJudgeScoreRequest
+): Promise> => {
+ const res = await api.post>(
+ `/judge/hackathons/${hackathonId}/submissions/${submissionId}/score`,
+ payload
+ );
+ return res.data;
+};
+
+// ---------------------------------------------------------------------------
+// Endpoints — invitations
+// ---------------------------------------------------------------------------
+
+export const getJudgeInvitations = async (): Promise<
+ ApiResponse
+> => {
+ const res =
+ await api.get>('/judge/invitations');
+ return res.data;
+};
+
+export const previewJudgeInvitation = async (
+ token: string
+): Promise> => {
+ const res = await api.get>(
+ `/judge/invitations/${token}`
+ );
+ return res.data;
+};
+
+export const acceptJudgeInvitation = async (
+ token: string,
+ payload: { displayName?: string } = {}
+): Promise> => {
+ const res = await api.post>(
+ `/judge/invitations/${token}/accept`,
+ payload
+ );
+ return res.data;
+};
+
+export const declineJudgeInvitation = async (
+ token: string
+): Promise> => {
+ const res = await api.post>(
+ `/judge/invitations/${token}/decline`
+ );
+ return res.data;
+};
+
+// ---------------------------------------------------------------------------
+// Endpoints — organizer-side invitation management
+// ---------------------------------------------------------------------------
+
+export interface OrganizerInvitationSummary {
+ id: string;
+ hackathonId: string;
+ email: string;
+ status: JudgeInvitationStatus;
+ invitedAt: string;
+ expiresAt: string;
+ acceptedAt: string | null;
+ declinedAt: string | null;
+ revokedAt: string | null;
+ resendCount: number;
+ lastResentAt: string | null;
+ displayName: string | null;
+ message: string | null;
+ invitedBy: { id: string; name: string; image: string | null } | null;
+ acceptedByUser: { id: string; name: string; image: string | null } | null;
+}
+
+export interface InviteJudgePayload {
+ email: string;
+ displayName?: string;
+ message?: string;
+ expiresInDays?: number;
+}
+
+export const inviteJudge = async (
+ organizationId: string,
+ hackathonIdOrSlug: string,
+ payload: InviteJudgePayload
+): Promise> => {
+ const res = await api.post>(
+ `/organizations/${organizationId}/hackathons/${hackathonIdOrSlug}/judging/invitations`,
+ payload
+ );
+ return res.data;
+};
+
+export const listJudgeInvitations = async (
+ organizationId: string,
+ hackathonIdOrSlug: string,
+ status?: JudgeInvitationStatus
+): Promise> => {
+ const qs = status ? `?status=${status}` : '';
+ const res = await api.get>(
+ `/organizations/${organizationId}/hackathons/${hackathonIdOrSlug}/judging/invitations${qs}`
+ );
+ return res.data;
+};
+
+export const cancelJudgeInvitation = async (
+ organizationId: string,
+ hackathonIdOrSlug: string,
+ invitationId: string
+): Promise> => {
+ const res = await api.delete>(
+ `/organizations/${organizationId}/hackathons/${hackathonIdOrSlug}/judging/invitations/${invitationId}`
+ );
+ return res.data;
+};
+
+export const resendJudgeInvitation = async (
+ organizationId: string,
+ hackathonIdOrSlug: string,
+ invitationId: string
+): Promise> => {
+ const res = await api.post>(
+ `/organizations/${organizationId}/hackathons/${hackathonIdOrSlug}/judging/invitations/${invitationId}/resend`
+ );
+ return res.data;
+};
diff --git a/lib/utils/hackathon-form-transforms.ts b/lib/utils/hackathon-form-transforms.ts
index 3db8245ae..52c2e2e32 100644
--- a/lib/utils/hackathon-form-transforms.ts
+++ b/lib/utils/hackathon-form-transforms.ts
@@ -126,6 +126,9 @@ export const transformToApiFormat = (stepData: {
judging: {
criteria:
judging?.criteria?.map(criterion => ({
+ // Preserve the server-issued id where present so the UI keys
+ // stay stable across edits.
+ id: criterion.id ?? criterion.name ?? '',
title: criterion.name,
weight: criterion.weight,
description: criterion.description,
diff --git a/types/hackathon/core.ts b/types/hackathon/core.ts
index e7e87176e..738aa45bf 100644
--- a/types/hackathon/core.ts
+++ b/types/hackathon/core.ts
@@ -117,7 +117,9 @@ export interface HackathonRewards {
}
export interface JudgingCriterion {
- id?: string;
+ // Server-side guarantee: persisted criteria always have a non-empty
+ // unique id. Local drafts may briefly lack one before they're sent.
+ id: string;
name?: string;
title: string;
weight: number; // 0-100