Remove dynamic form builder and complete RoundProject→roundId migration
All checks were successful
Build and Push Docker Image / build (push) Successful in 14m3s
All checks were successful
Build and Push Docker Image / build (push) Successful in 14m3s
Major cleanup and schema migration: - Remove unused dynamic form builder system (ApplicationForm, ApplicationFormField, etc.) - Complete migration from RoundProject junction table to direct Project.roundId - Add sortOrder and entryNotificationType fields to Round model - Add country field to User model for mentor matching - Enhance onboarding with profile photo and country selection steps - Fix all TypeScript errors related to roundProjects references - Remove unused libraries (@radix-ui/react-toast, embla-carousel-react, vaul) Files removed: - admin/forms/* pages and related components - admin/onboarding/* pages - applicationForm.ts and onboarding.ts routers - Dynamic form builder Prisma models and enums Schema changes: - Removed ApplicationForm, ApplicationFormField, OnboardingStep, ApplicationFormSubmission, SubmissionFile models - Removed FormFieldType and SpecialFieldType enums - Added Round.sortOrder, Round.entryNotificationType - Added User.country Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,430 +1,423 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Waves,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
StepWelcome,
|
||||
StepContact,
|
||||
StepProject,
|
||||
StepTeam,
|
||||
StepAdditional,
|
||||
StepReview,
|
||||
} from '@/components/forms/apply-steps'
|
||||
import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type FormField = {
|
||||
id: string
|
||||
fieldType: string
|
||||
name: string
|
||||
label: string
|
||||
description?: string | null
|
||||
placeholder?: string | null
|
||||
required: boolean
|
||||
minLength?: number | null
|
||||
maxLength?: number | null
|
||||
minValue?: number | null
|
||||
maxValue?: number | null
|
||||
optionsJson: Array<{ value: string; label: string }> | null
|
||||
conditionJson: { fieldId: string; operator: string; value?: string } | null
|
||||
width: string
|
||||
}
|
||||
// Form validation schema
|
||||
const teamMemberSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
role: z.nativeEnum(TeamMemberRole).default('MEMBER'),
|
||||
title: z.string().optional(),
|
||||
})
|
||||
|
||||
export default function PublicFormPage() {
|
||||
const applicationSchema = z.object({
|
||||
competitionCategory: z.nativeEnum(CompetitionCategory),
|
||||
contactName: z.string().min(2, 'Full name is required'),
|
||||
contactEmail: z.string().email('Invalid email address'),
|
||||
contactPhone: z.string().min(5, 'Phone number is required'),
|
||||
country: z.string().min(2, 'Country is required'),
|
||||
city: z.string().optional(),
|
||||
projectName: z.string().min(2, 'Project name is required').max(200),
|
||||
teamName: z.string().optional(),
|
||||
description: z.string().min(20, 'Description must be at least 20 characters'),
|
||||
oceanIssue: z.nativeEnum(OceanIssue),
|
||||
teamMembers: z.array(teamMemberSchema).optional(),
|
||||
institution: z.string().optional(),
|
||||
startupCreatedDate: z.string().optional(),
|
||||
wantsMentorship: z.boolean().default(false),
|
||||
referralSource: z.string().optional(),
|
||||
gdprConsent: z.boolean().refine((val) => val === true, {
|
||||
message: 'You must agree to the data processing terms',
|
||||
}),
|
||||
})
|
||||
|
||||
type ApplicationFormData = z.infer<typeof applicationSchema>
|
||||
|
||||
const STEPS = [
|
||||
{ id: 'welcome', title: 'Category', fields: ['competitionCategory'] },
|
||||
{ id: 'contact', title: 'Contact', fields: ['contactName', 'contactEmail', 'contactPhone', 'country'] },
|
||||
{ id: 'project', title: 'Project', fields: ['projectName', 'description', 'oceanIssue'] },
|
||||
{ id: 'team', title: 'Team', fields: [] },
|
||||
{ id: 'additional', title: 'Details', fields: [] },
|
||||
{ id: 'review', title: 'Review', fields: ['gdprConsent'] },
|
||||
]
|
||||
|
||||
export default function ApplyWizardPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const slug = params.slug as string
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [confirmationMessage, setConfirmationMessage] = useState<string | null>(null)
|
||||
|
||||
const { data: form, isLoading, error } = trpc.applicationForm.getBySlug.useQuery(
|
||||
{ slug },
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [direction, setDirection] = useState(0)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [submissionMessage, setSubmissionMessage] = useState('')
|
||||
|
||||
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
|
||||
{ roundSlug: slug },
|
||||
{ retry: false }
|
||||
)
|
||||
|
||||
const submitMutation = trpc.applicationForm.submit.useMutation({
|
||||
const submitMutation = trpc.application.submit.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setSubmitted(true)
|
||||
setConfirmationMessage(result.confirmationMessage || null)
|
||||
setSubmissionMessage(result.message)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors, isSubmitting },
|
||||
setValue,
|
||||
} = useForm()
|
||||
const form = useForm<ApplicationFormData>({
|
||||
resolver: zodResolver(applicationSchema),
|
||||
defaultValues: {
|
||||
competitionCategory: undefined,
|
||||
contactName: '',
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
country: '',
|
||||
city: '',
|
||||
projectName: '',
|
||||
teamName: '',
|
||||
description: '',
|
||||
oceanIssue: undefined,
|
||||
teamMembers: [],
|
||||
institution: '',
|
||||
startupCreatedDate: '',
|
||||
wantsMentorship: false,
|
||||
referralSource: '',
|
||||
gdprConsent: false,
|
||||
},
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const watchedValues = watch()
|
||||
const { watch, trigger, handleSubmit } = form
|
||||
const competitionCategory = watch('competitionCategory')
|
||||
|
||||
const onSubmit = async (data: Record<string, unknown>) => {
|
||||
if (!form) return
|
||||
const isBusinessConcept = competitionCategory === 'BUSINESS_CONCEPT'
|
||||
const isStartup = competitionCategory === 'STARTUP'
|
||||
|
||||
// Extract email and name if present
|
||||
const emailField = form.fields.find((f) => f.fieldType === 'EMAIL')
|
||||
const email = emailField ? (data[emailField.name] as string) : undefined
|
||||
const validateCurrentStep = async () => {
|
||||
const currentFields = STEPS[currentStep].fields as (keyof ApplicationFormData)[]
|
||||
if (currentFields.length === 0) return true
|
||||
return await trigger(currentFields)
|
||||
}
|
||||
|
||||
// Find a name field (common patterns)
|
||||
const nameField = form.fields.find(
|
||||
(f) => f.name.toLowerCase().includes('name') && f.fieldType === 'TEXT'
|
||||
)
|
||||
const name = nameField ? (data[nameField.name] as string) : undefined
|
||||
const nextStep = async () => {
|
||||
const isValid = await validateCurrentStep()
|
||||
if (isValid && currentStep < STEPS.length - 1) {
|
||||
setDirection(1)
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep > 0) {
|
||||
setDirection(-1)
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (data: ApplicationFormData) => {
|
||||
if (!config) return
|
||||
await submitMutation.mutateAsync({
|
||||
formId: form.id,
|
||||
roundId: config.round.id,
|
||||
data,
|
||||
email,
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && currentStep < STEPS.length - 1) {
|
||||
e.preventDefault()
|
||||
nextStep()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [currentStep])
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-2xl space-y-6">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="text-lg text-muted-foreground">Loading application...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Form Not Available</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
{error.message}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
|
||||
<p className="text-muted-foreground mb-6">{error.message}</p>
|
||||
<Button variant="outline" onClick={() => router.push('/')}>
|
||||
Return Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Applications closed state
|
||||
if (config && !config.round.isOpen) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<Clock className="mx-auto h-16 w-16 text-muted-foreground mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-2">Applications Closed</h1>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
The application period for {config.program.name} {config.program.year} has ended.
|
||||
{config.round.submissionEndDate && (
|
||||
<span className="block mt-2">
|
||||
Submissions closed on{' '}
|
||||
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
|
||||
dateStyle: 'long',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => router.push('/')}>
|
||||
Return Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Thank You!</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
{confirmationMessage || 'Your submission has been received.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="w-full max-w-md text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: 'spring' }}
|
||||
>
|
||||
<CheckCircle className="mx-auto h-20 w-20 text-green-500 mb-6" />
|
||||
</motion.div>
|
||||
<h1 className="text-3xl font-bold mb-4">Application Submitted!</h1>
|
||||
<p className="text-muted-foreground mb-8">{submissionMessage}</p>
|
||||
<Button onClick={() => router.push('/')}>
|
||||
Return Home
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!form) return null
|
||||
if (!config) return null
|
||||
|
||||
// Check if a field should be visible based on conditions
|
||||
const isFieldVisible = (field: FormField): boolean => {
|
||||
if (!field.conditionJson) return true
|
||||
const progress = ((currentStep + 1) / STEPS.length) * 100
|
||||
|
||||
const condition = field.conditionJson
|
||||
const dependentValue = watchedValues[form.fields.find((f) => f.id === condition.fieldId)?.name || '']
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'equals':
|
||||
return dependentValue === condition.value
|
||||
case 'not_equals':
|
||||
return dependentValue !== condition.value
|
||||
case 'not_empty':
|
||||
return !!dependentValue && dependentValue !== ''
|
||||
case 'contains':
|
||||
return typeof dependentValue === 'string' && dependentValue.includes(condition.value || '')
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const renderField = (field: FormField) => {
|
||||
if (!isFieldVisible(field)) return null
|
||||
|
||||
const fieldError = errors[field.name]
|
||||
const errorMessage = fieldError?.message as string | undefined
|
||||
|
||||
switch (field.fieldType) {
|
||||
case 'SECTION':
|
||||
return (
|
||||
<div key={field.id} className="col-span-full pt-6 pb-2">
|
||||
<h3 className="text-lg font-semibold">{field.label}</h3>
|
||||
{field.description && (
|
||||
<p className="text-sm text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'INSTRUCTIONS':
|
||||
return (
|
||||
<div key={field.id} className="col-span-full">
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<p className="text-sm">{field.description || field.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'TEXT':
|
||||
case 'EMAIL':
|
||||
case 'PHONE':
|
||||
case 'URL':
|
||||
return (
|
||||
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
|
||||
)}
|
||||
<Input
|
||||
id={field.name}
|
||||
type={field.fieldType === 'EMAIL' ? 'email' : field.fieldType === 'URL' ? 'url' : 'text'}
|
||||
placeholder={field.placeholder || undefined}
|
||||
{...register(field.name, {
|
||||
required: field.required ? `${field.label} is required` : false,
|
||||
minLength: field.minLength ? { value: field.minLength, message: `Minimum ${field.minLength} characters` } : undefined,
|
||||
maxLength: field.maxLength ? { value: field.maxLength, message: `Maximum ${field.maxLength} characters` } : undefined,
|
||||
})}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'NUMBER':
|
||||
return (
|
||||
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
|
||||
)}
|
||||
<Input
|
||||
id={field.name}
|
||||
type="number"
|
||||
placeholder={field.placeholder || undefined}
|
||||
{...register(field.name, {
|
||||
required: field.required ? `${field.label} is required` : false,
|
||||
valueAsNumber: true,
|
||||
min: field.minValue ? { value: field.minValue, message: `Minimum value is ${field.minValue}` } : undefined,
|
||||
max: field.maxValue ? { value: field.maxValue, message: `Maximum value is ${field.maxValue}` } : undefined,
|
||||
})}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'TEXTAREA':
|
||||
return (
|
||||
<div key={field.id} className="col-span-full">
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
|
||||
)}
|
||||
<Textarea
|
||||
id={field.name}
|
||||
placeholder={field.placeholder || undefined}
|
||||
rows={4}
|
||||
{...register(field.name, {
|
||||
required: field.required ? `${field.label} is required` : false,
|
||||
minLength: field.minLength ? { value: field.minLength, message: `Minimum ${field.minLength} characters` } : undefined,
|
||||
maxLength: field.maxLength ? { value: field.maxLength, message: `Maximum ${field.maxLength} characters` } : undefined,
|
||||
})}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'DATE':
|
||||
case 'DATETIME':
|
||||
return (
|
||||
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
|
||||
)}
|
||||
<Input
|
||||
id={field.name}
|
||||
type={field.fieldType === 'DATETIME' ? 'datetime-local' : 'date'}
|
||||
{...register(field.name, {
|
||||
required: field.required ? `${field.label} is required` : false,
|
||||
})}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'SELECT':
|
||||
return (
|
||||
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
|
||||
)}
|
||||
<Select
|
||||
onValueChange={(value) => setValue(field.name, value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.placeholder || 'Select an option'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.optionsJson || []).map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register(field.name, {
|
||||
required: field.required ? `${field.label} is required` : false,
|
||||
})}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'RADIO':
|
||||
return (
|
||||
<div key={field.id} className="col-span-full">
|
||||
<Label>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
|
||||
)}
|
||||
<RadioGroup
|
||||
onValueChange={(value) => setValue(field.name, value)}
|
||||
className="mt-2"
|
||||
>
|
||||
{(field.optionsJson || []).map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option.value} id={`${field.name}-${option.value}`} />
|
||||
<Label htmlFor={`${field.name}-${option.value}`} className="font-normal">
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register(field.name, {
|
||||
required: field.required ? `${field.label} is required` : false,
|
||||
})}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'CHECKBOX':
|
||||
return (
|
||||
<div key={field.id} className="col-span-full">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={field.name}
|
||||
onCheckedChange={(checked) => setValue(field.name, checked)}
|
||||
/>
|
||||
<Label htmlFor={field.name} className="font-normal">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
</div>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground ml-6">{field.description}</p>
|
||||
)}
|
||||
<input
|
||||
type="hidden"
|
||||
{...register(field.name, {
|
||||
validate: field.required ? (value) => value === true || `${field.label} is required` : undefined,
|
||||
})}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
const variants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 50 : -50,
|
||||
opacity: 0,
|
||||
}),
|
||||
center: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
x: direction < 0 ? 50 : -50,
|
||||
opacity: 0,
|
||||
}),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{form.name}</CardTitle>
|
||||
{form.description && (
|
||||
<CardDescription>{form.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{form.fields.map((field) => renderField(field as FormField))}
|
||||
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="mx-auto max-w-4xl px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Waves className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-semibold">{config.program.name}</h1>
|
||||
<p className="text-xs text-muted-foreground">{config.program.year} Application</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Step {currentStep + 1} of {STEPS.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mt-4 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-primary to-primary/70"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="mt-3 flex justify-between">
|
||||
{STEPS.map((step, index) => (
|
||||
<button
|
||||
key={step.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (index < currentStep) {
|
||||
setDirection(index < currentStep ? -1 : 1)
|
||||
setCurrentStep(index)
|
||||
}
|
||||
}}
|
||||
disabled={index > currentStep}
|
||||
className={cn(
|
||||
'hidden text-xs font-medium transition-colors sm:block',
|
||||
index === currentStep && 'text-primary',
|
||||
index < currentStep && 'text-muted-foreground hover:text-foreground cursor-pointer',
|
||||
index > currentStep && 'text-muted-foreground/50'
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="relative min-h-[500px]">
|
||||
<AnimatePresence initial={false} custom={direction} mode="wait">
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
custom={direction}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{
|
||||
x: { type: 'spring', stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.2 },
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
{currentStep === 0 && (
|
||||
<StepWelcome
|
||||
programName={config.program.name}
|
||||
programYear={config.program.year}
|
||||
value={competitionCategory}
|
||||
onChange={(value) => form.setValue('competitionCategory', value)}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 1 && <StepContact form={form} />}
|
||||
{currentStep === 2 && <StepProject form={form} />}
|
||||
{currentStep === 3 && <StepTeam form={form} />}
|
||||
{currentStep === 4 && (
|
||||
<StepAdditional
|
||||
form={form}
|
||||
isBusinessConcept={isBusinessConcept}
|
||||
isStartup={isStartup}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 5 && (
|
||||
<StepReview form={form} programName={config.program.name} />
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="mt-8 flex items-center justify-between border-t pt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || submitMutation.isPending}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={prevStep}
|
||||
disabled={currentStep === 0 || submitMutation.isPending}
|
||||
className={cn(currentStep === 0 && 'invisible')}
|
||||
>
|
||||
{(isSubmitting || submitMutation.isPending) && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Submit
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{currentStep < STEPS.length - 1 ? (
|
||||
<Button type="button" onClick={nextStep}>
|
||||
Continue
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" disabled={submitMutation.isPending}>
|
||||
{submitMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Submit Application
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
{/* Footer with deadline info */}
|
||||
{config.round.submissionEndDate && (
|
||||
<footer className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3">
|
||||
<div className="mx-auto max-w-4xl px-4 text-center text-sm text-muted-foreground">
|
||||
<Clock className="inline-block mr-1 h-4 w-4" />
|
||||
Applications due by{' '}
|
||||
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
|
||||
dateStyle: 'long',
|
||||
})}
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user