Skip to content

Commit f2d3f5c

Browse files
authored
fix(submissions): improve link validation UX, banner display, and error toasts (#547)
- Reconcile frontend link types with backend enum (github, demo, video, document, presentation, other) - Allow up to 5 "other" links per submission with smart type-picking on Add Link and duplicate guards on type change - Add 500-character cap and live counter on the optional Introduction field - Pass banner through initialData on submission edit so the saved banner displays - Add banner field to ParticipantSubmission so the type compiles - Set mock-fill participation type from myTeam to avoid INDIVIDUAL submissions while on a team - Switch submission error toasts to title + description with 8s duration so backend messages are readable - Update SubmissionLinksTab icon mapping for the new link types
1 parent 3797a08 commit f2d3f5c

5 files changed

Lines changed: 109 additions & 15 deletions

File tree

app/(landing)/hackathons/[slug]/submit/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export default function SubmitProjectPage({
156156
category: mySubmission.category,
157157
description: mySubmission.description,
158158
logo: mySubmission.logo,
159+
banner: mySubmission.banner,
159160
videoUrl: mySubmission.videoUrl,
160161
introduction: mySubmission.introduction,
161162
links: mySubmission.links,

components/hackathons/submissions/SubmissionForm.tsx

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,10 @@ const baseSubmissionSchema = z.object({
9292
videoUrl: z
9393
.union([z.string().url('Please enter a valid URL'), z.literal('')])
9494
.optional(),
95-
introduction: z.string().optional(),
95+
introduction: z
96+
.string()
97+
.max(500, 'Introduction cannot exceed 500 characters')
98+
.optional(),
9699
links: z.array(
97100
z.object({
98101
type: z.string(),
@@ -155,12 +158,18 @@ const INITIAL_STEPS: Step[] = [
155158
const LINK_TYPES = [
156159
{ value: 'github', label: 'GitHub' },
157160
{ value: 'demo', label: 'Demo' },
158-
{ value: 'website', label: 'Website' },
159-
{ value: 'documentation', label: 'Documentation' },
161+
{ value: 'video', label: 'Video' },
162+
{ value: 'document', label: 'Document' },
163+
{ value: 'presentation', label: 'Presentation' },
160164
{ value: 'other', label: 'Other' },
161165
];
162166

163-
const OTHER_LINK_TYPES = ['demo', 'website', 'documentation', 'other'];
167+
const OTHER_LINK_TYPES = ['demo', 'video', 'document', 'presentation', 'other'];
168+
169+
const MAX_OTHER_LINKS = 5;
170+
const FIXED_LINK_TYPES = LINK_TYPES.map(t => t.value).filter(
171+
v => v !== 'other'
172+
);
164173

165174
const isValidUrl = (url: string | undefined): boolean => {
166175
if (!url || String(url).trim() === '') return false;
@@ -517,6 +526,9 @@ const SubmissionFormContent: React.FC<SubmissionFormContentProps> = ({
517526
};
518527

519528
const handleFillMockData = () => {
529+
const participationType: 'INDIVIDUAL' | 'TEAM' = myTeam
530+
? 'TEAM'
531+
: 'INDIVIDUAL';
520532
const mockData = {
521533
projectName: 'AI-Powered Task Manager',
522534
category: categoryOptions[0],
@@ -529,7 +541,7 @@ const SubmissionFormContent: React.FC<SubmissionFormContentProps> = ({
529541
{ type: 'github', url: 'https://github.com/example/ai-task-manager' },
530542
{ type: 'demo', url: 'https://demo.example.com/ai-task-manager' },
531543
],
532-
participationType: 'INDIVIDUAL' as const,
544+
participationType,
533545
};
534546

535547
form.reset(mockData);
@@ -539,7 +551,27 @@ const SubmissionFormContent: React.FC<SubmissionFormContentProps> = ({
539551

540552
const handleAddLink = () => {
541553
const currentLinks = form.getValues('links') || [];
542-
form.setValue('links', [...currentLinks, { type: 'github', url: '' }], {
554+
const usedFixedTypes = new Set(
555+
currentLinks.map(l => l.type).filter(t => t !== 'other')
556+
);
557+
const otherCount = currentLinks.filter(l => l.type === 'other').length;
558+
559+
const firstUnusedFixed = FIXED_LINK_TYPES.find(t => !usedFixedTypes.has(t));
560+
561+
let nextType: string;
562+
if (firstUnusedFixed) {
563+
nextType = firstUnusedFixed;
564+
} else if (otherCount < MAX_OTHER_LINKS) {
565+
nextType = 'other';
566+
} else {
567+
toast.error('Cannot add another link', {
568+
description: `All fixed link types are used and you have reached the limit of ${MAX_OTHER_LINKS} "Other" links.`,
569+
duration: 6000,
570+
});
571+
return;
572+
}
573+
574+
form.setValue('links', [...currentLinks, { type: nextType, url: '' }], {
543575
shouldValidate: false,
544576
});
545577
};
@@ -582,6 +614,33 @@ const SubmissionFormContent: React.FC<SubmissionFormContentProps> = ({
582614
value: string
583615
) => {
584616
const currentLinks = form.getValues('links') || [];
617+
618+
if (field === 'type') {
619+
if (value !== 'other') {
620+
const isDuplicate = currentLinks.some(
621+
(l, i) => i !== index && l.type === value
622+
);
623+
if (isDuplicate) {
624+
toast.error('Duplicate link type', {
625+
description: `"${value}" is already used. Each link type can be used at most once. Choose "Other" for additional links.`,
626+
duration: 6000,
627+
});
628+
return;
629+
}
630+
} else {
631+
const otherCount = currentLinks.filter(
632+
(l, i) => i !== index && l.type === 'other'
633+
).length;
634+
if (otherCount >= MAX_OTHER_LINKS) {
635+
toast.error('Too many "Other" links', {
636+
description: `You can include at most ${MAX_OTHER_LINKS} "Other" links per submission.`,
637+
duration: 6000,
638+
});
639+
return;
640+
}
641+
}
642+
}
643+
585644
const updatedLinks = [...currentLinks];
586645
updatedLinks[index] = { ...updatedLinks[index], [field]: value };
587646
form.setValue('links', updatedLinks, { shouldValidate: true });
@@ -643,7 +702,7 @@ const SubmissionFormContent: React.FC<SubmissionFormContentProps> = ({
643702
if (requireOtherLinks && !hasValidOtherLink) {
644703
form.setError('links', {
645704
message:
646-
'At least one additional link (Demo, Website, Documentation, or Other) is required for this hackathon.',
705+
'At least one additional link (Demo, Video, Document, Presentation, or Other) is required for this hackathon.',
647706
});
648707
return;
649708
}
@@ -792,7 +851,7 @@ const SubmissionFormContent: React.FC<SubmissionFormContentProps> = ({
792851
if (requireOtherLinks && !hasValidOtherLink) {
793852
form.setError('links', {
794853
message:
795-
'At least one additional link (Demo, Website, Documentation, or Other) is required for this hackathon.',
854+
'At least one additional link (Demo, Video, Document, Presentation, or Other) is required for this hackathon.',
796855
});
797856
setCurrentStep(2);
798857
updateStepState(2, 'active');
@@ -1346,12 +1405,13 @@ const SubmissionFormContent: React.FC<SubmissionFormContentProps> = ({
13461405
<FormControl>
13471406
<Textarea
13481407
placeholder='Tell us more about your project...'
1408+
maxLength={500}
13491409
className='min-h-[100px] border-gray-700 bg-gray-800/50 text-white placeholder:text-gray-500'
13501410
{...field}
13511411
/>
13521412
</FormControl>
13531413
<FormDescription className='text-gray-400'>
1354-
Optional: Additional information about your project
1414+
Optional. {field.value?.length || 0} / 500 characters max
13551415
</FormDescription>
13561416
<FormMessage />
13571417
</FormItem>
@@ -1386,12 +1446,17 @@ const SubmissionFormContent: React.FC<SubmissionFormContentProps> = ({
13861446
{(requireGithub || requireOtherLinks) && (
13871447
<FormDescription className='text-gray-400'>
13881448
{requireGithub && requireOtherLinks
1389-
? 'GitHub repository link and at least one additional link (Demo, Website, Documentation, or Other) are required for this hackathon.'
1449+
? 'GitHub repository link and at least one additional link (Demo, Video, Document, Presentation, or Other) are required for this hackathon.'
13901450
: requireGithub
13911451
? 'GitHub repository link is required for this hackathon.'
1392-
: 'At least one additional link (Demo, Website, Documentation, or Other) is required for this hackathon.'}
1452+
: 'At least one additional link (Demo, Video, Document, Presentation, or Other) is required for this hackathon.'}
13931453
</FormDescription>
13941454
)}
1455+
<FormDescription className='text-gray-400'>
1456+
Each link type can be used at most once. For additional
1457+
links, choose &quot;Other&quot; (up to {MAX_OTHER_LINKS}{' '}
1458+
allowed).
1459+
</FormDescription>
13951460
{formLinks.length === 0 ? (
13961461
<p className='text-sm text-gray-400'>
13971462
No links added. Click "Add Link" to add project links.

components/organization/cards/ReviewSubmissionModal/SubmissionLinksTab.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
'use client';
22

33
import React from 'react';
4-
import { ArrowUpRight, Github, Twitter, Globe, Link2 } from 'lucide-react';
4+
import {
5+
ArrowUpRight,
6+
Github,
7+
Twitter,
8+
Globe,
9+
Link2,
10+
Video,
11+
FileText,
12+
Presentation,
13+
Play,
14+
} from 'lucide-react';
515
import { ScrollArea } from '@/components/ui/scroll-area';
616
import { motion } from 'motion/react';
717

@@ -15,6 +25,14 @@ const getIcon = (type: string) => {
1525
return <Github className='h-5 w-5' />;
1626
case 'twitter':
1727
return <Twitter className='h-5 w-5' />;
28+
case 'demo':
29+
return <Play className='h-5 w-5' />;
30+
case 'video':
31+
return <Video className='h-5 w-5' />;
32+
case 'document':
33+
return <FileText className='h-5 w-5' />;
34+
case 'presentation':
35+
return <Presentation className='h-5 w-5' />;
1836
case 'website':
1937
return <Globe className='h-5 w-5' />;
2038
default:

hooks/hackathon/use-submission.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,10 @@ export function useSubmission({
134134
'Failed to create submission'
135135
);
136136
setError(errorMessage);
137-
toast.error(errorMessage);
137+
toast.error('Submission failed', {
138+
description: errorMessage,
139+
duration: 8000,
140+
});
138141
reportError(err, {
139142
context: 'hackathon-createSubmission',
140143
hackathonSlugOrId,
@@ -180,7 +183,10 @@ export function useSubmission({
180183
'Failed to update submission'
181184
);
182185
setError(errorMessage);
183-
toast.error(errorMessage);
186+
toast.error('Update failed', {
187+
description: errorMessage,
188+
duration: 8000,
189+
});
184190
reportError(err, {
185191
context: 'hackathon-updateSubmission',
186192
submissionId,
@@ -214,7 +220,10 @@ export function useSubmission({
214220
'Failed to delete submission'
215221
);
216222
setError(errorMessage);
217-
toast.error(errorMessage);
223+
toast.error('Delete failed', {
224+
description: errorMessage,
225+
duration: 8000,
226+
});
218227
throw err;
219228
} finally {
220229
setIsSubmitting(false);

lib/api/hackathons.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,7 @@ export interface ParticipantSubmission {
702702
category: string;
703703
description: string;
704704
logo?: string;
705+
banner?: string;
705706
videoUrl?: string;
706707
introduction?: string;
707708
links?: Array<{ type: string; url: string }>;

0 commit comments

Comments
 (0)