diff --git a/components/hackathons/submissions/SubmissionForm.tsx b/components/hackathons/submissions/SubmissionForm.tsx index 2b9135da3..fdbcdcd0e 100644 --- a/components/hackathons/submissions/SubmissionForm.tsx +++ b/components/hackathons/submissions/SubmissionForm.tsx @@ -1,5 +1,11 @@ 'use client'; -import React, { useState, useCallback, useEffect, useRef } from 'react'; +import React, { + useState, + useCallback, + useEffect, + useRef, + useMemo, +} from 'react'; import { useForm } from 'react-hook-form'; import { cn } from '@/lib/utils'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -77,7 +83,7 @@ const teamMemberSchema = z path: ['email'], }); -const submissionSchema = z.object({ +const baseSubmissionSchema = z.object({ projectName: z.string().min(3, 'Project name must be at least 3 characters'), category: z.string().min(1, 'Please select a category'), description: z.string().min(50, 'Description must be at least 50 characters'), @@ -89,7 +95,7 @@ const submissionSchema = z.object({ links: z.array( z.object({ type: z.string(), - url: z.string().url('Please enter a valid URL'), + url: z.union([z.string().url('Please enter a valid URL'), z.literal('')]), }) ), participationType: z.enum(['INDIVIDUAL', 'TEAM']), @@ -97,7 +103,21 @@ const submissionSchema = z.object({ teamMembers: z.array(teamMemberSchema).optional(), }); -type SubmissionFormDataLocal = z.infer; +const createSubmissionSchema = (requireDemoVideo: boolean) => + requireDemoVideo + ? baseSubmissionSchema.refine( + data => + data.videoUrl !== undefined && + data.videoUrl !== null && + String(data.videoUrl).trim() !== '', + { + message: 'Demo video URL is required for this hackathon', + path: ['videoUrl'], + } + ) + : baseSubmissionSchema; + +type SubmissionFormDataLocal = z.infer; interface SubmissionFormContentProps { hackathonSlugOrId: string; @@ -138,6 +158,8 @@ const LINK_TYPES = [ { value: 'other', label: 'Other' }, ]; +const OTHER_LINK_TYPES = ['demo', 'website', 'documentation', 'other']; + const CATEGORIES = [ 'Web Development', 'Mobile App', @@ -149,6 +171,16 @@ const CATEGORIES = [ 'Other', ]; +const isValidUrl = (url: string | undefined): boolean => { + if (!url || String(url).trim() === '') return false; + try { + const u = new URL(String(url).trim()); + return u.protocol === 'http:' || u.protocol === 'https:'; + } catch { + return false; + } +}; + const isValidImageUrl = (url: string | undefined): boolean => { if (!url || url.trim() === '') return false; // Check if it's a data URL @@ -170,6 +202,16 @@ const SubmissionFormContent: React.FC = ({ const { collapse, isExpanded: open } = useExpandableScreen(); const { currentHackathon } = useHackathonData(); const { user } = useAuthStatus(); + + const requireGithub = currentHackathon?.requireGithub ?? false; + const requireDemoVideo = currentHackathon?.requireDemoVideo ?? false; + const requireOtherLinks = currentHackathon?.requireOtherLinks ?? false; + + const submissionSchema = useMemo( + () => createSubmissionSchema(requireDemoVideo), + [requireDemoVideo] + ); + const [currentStep, setCurrentStep] = useState(0); const [steps, setSteps] = useState(INITIAL_STEPS); const [logoPreview, setLogoPreview] = useState(''); @@ -383,7 +425,30 @@ const SubmissionFormContent: React.FC = ({ }); }; + const canRemoveLink = (index: number): boolean => { + const links = form.getValues('links') || []; + const link = links[index]; + if (!link) return true; + + if (requireGithub && link.type === 'github') { + const otherGithubCount = links.filter( + (l, i) => i !== index && l.type === 'github' + ).length; + if (otherGithubCount === 0) return false; + } + + if (requireOtherLinks && OTHER_LINK_TYPES.includes(link.type)) { + const otherLinkCount = links.filter( + (l, i) => i !== index && OTHER_LINK_TYPES.includes(l.type) + ).length; + if (otherLinkCount === 0) return false; + } + + return true; + }; + const handleRemoveLink = (index: number) => { + if (!canRemoveLink(index)) return; const currentLinks = form.getValues('links') || []; form.setValue( 'links', @@ -463,11 +528,50 @@ const SubmissionFormContent: React.FC = ({ const videoUrl = form.getValues('videoUrl'); const links = form.getValues('links') || []; - if (videoUrl && videoUrl.trim() !== '') { + form.clearErrors(['videoUrl', 'links']); + + if (requireDemoVideo) { + const videoTrimmed = + videoUrl !== undefined && videoUrl !== null + ? String(videoUrl).trim() + : ''; + if (videoTrimmed === '') { + form.setError('videoUrl', { + message: 'Demo video URL is required for this hackathon', + }); + return; + } + const videoValid = await form.trigger('videoUrl'); + if (!videoValid) return; + } else if (videoUrl && String(videoUrl).trim() !== '') { const videoValid = await form.trigger('videoUrl'); if (!videoValid) return; } + const hasValidGithubLink = links.some( + link => link.type === 'github' && isValidUrl(link.url) + ); + + if (requireGithub && !hasValidGithubLink) { + form.setError('links', { + message: + 'GitHub repository link is required for this hackathon. Please add your GitHub link.', + }); + return; + } + + const hasValidOtherLink = links.some( + link => OTHER_LINK_TYPES.includes(link.type) && isValidUrl(link.url) + ); + + if (requireOtherLinks && !hasValidOtherLink) { + form.setError('links', { + message: + 'At least one additional link (Demo, Website, Documentation, or Other) is required for this hackathon.', + }); + return; + } + if (links.length > 0) { const linksValid = await form.trigger('links'); if (!linksValid) return; @@ -559,6 +663,61 @@ const SubmissionFormContent: React.FC = ({ return; } + form.clearErrors(['videoUrl', 'links']); + + if (requireDemoVideo) { + const videoVal = safeData.videoUrl ?? currentValues.videoUrl ?? ''; + const videoTrimmed = + videoVal !== undefined && videoVal !== null + ? String(videoVal).trim() + : ''; + if (videoTrimmed === '') { + form.setError('videoUrl', { + message: 'Demo video URL is required for this hackathon', + }); + setCurrentStep(2); + updateStepState(2, 'active'); + return; + } + const videoValid = await form.trigger('videoUrl'); + if (!videoValid) { + setCurrentStep(2); + updateStepState(2, 'active'); + return; + } + } + + const rawLinks = data.links ?? currentValues.links ?? []; + const hasValidGithubLink = rawLinks.some( + (link: { type: string; url: string }) => + link.type === 'github' && isValidUrl(link.url) + ); + + if (requireGithub && !hasValidGithubLink) { + form.setError('links', { + message: + 'GitHub repository link is required for this hackathon. Please add your GitHub link.', + }); + setCurrentStep(2); + updateStepState(2, 'active'); + return; + } + + const hasValidOtherLink = rawLinks.some( + (link: { type: string; url: string }) => + OTHER_LINK_TYPES.includes(link.type) && isValidUrl(link.url) + ); + + if (requireOtherLinks && !hasValidOtherLink) { + form.setError('links', { + message: + 'At least one additional link (Demo, Website, Documentation, or Other) is required for this hackathon.', + }); + setCurrentStep(2); + updateStepState(2, 'active'); + return; + } + const participationType = safeData.participationType || 'INDIVIDUAL'; const teamId = participationType === 'TEAM' ? myTeam?.id : undefined; @@ -757,7 +916,7 @@ const SubmissionFormContent: React.FC = ({ render={({ field }) => ( - Team Name * + Team Name * = ({ name='projectName' render={({ field }) => ( - Project Name * + + Project Name * + = ({ name='category' render={({ field }) => ( - Category * + + Category * +