-
- {/* 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.`}
+
@@ -236,7 +236,7 @@ export function SubmissionsSheetContent({
src={submission.logo}
alt='Project logo'
fill
- className='object-cover'
+ className='object-contain'
/>
@@ -450,7 +450,7 @@ export function TableRow({
src={submission.logo}
alt={submission.projectName}
fill
- className='object-cover'
+ className='object-contain'
/>
) : (
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/components/organization/hackathons/submissions/SubmissionsList.tsx b/components/organization/hackathons/submissions/SubmissionsList.tsx
index c1f414858..30029d9a1 100644
--- a/components/organization/hackathons/submissions/SubmissionsList.tsx
+++ b/components/organization/hackathons/submissions/SubmissionsList.tsx
@@ -16,6 +16,7 @@ import { reportError } from '@/lib/error-reporting';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
+import { BoundlessButton } from '@/components/buttons/BoundlessButton';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
@@ -157,10 +158,6 @@ export function SubmissionsList({
router.push(`/projects/${submissionId}?type=submission`);
};
- const isBeforeDeadline = hackathon?.submissionDeadline
- ? new Date() < new Date(hackathon.submissionDeadline)
- : false;
-
if (submissions.length === 0 && !loading) {
return (