Remove dynamic form builder and complete RoundProject→roundId migration
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,423 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
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 { toast } from 'sonner'
|
||||
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'
|
||||
|
||||
// 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(),
|
||||
})
|
||||
|
||||
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 [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.application.submit.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setSubmitted(true)
|
||||
setSubmissionMessage(result.message)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
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 { watch, trigger, handleSubmit } = form
|
||||
const competitionCategory = watch('competitionCategory')
|
||||
|
||||
const isBusinessConcept = competitionCategory === 'BUSINESS_CONCEPT'
|
||||
const isStartup = competitionCategory === 'STARTUP'
|
||||
|
||||
const validateCurrentStep = async () => {
|
||||
const currentFields = STEPS[currentStep].fields as (keyof ApplicationFormData)[]
|
||||
if (currentFields.length === 0) return true
|
||||
return await trigger(currentFields)
|
||||
}
|
||||
|
||||
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({
|
||||
roundId: config.round.id,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
// 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="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="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="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 (!config) return null
|
||||
|
||||
const progress = ((currentStep + 1) / STEPS.length) * 100
|
||||
|
||||
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="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="button"
|
||||
variant="ghost"
|
||||
onClick={prevStep}
|
||||
disabled={currentStep === 0 || submitMutation.isPending}
|
||||
className={cn(currentStep === 0 && 'invisible')}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,676 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, use } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardFooter,
|
||||
} 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 { Progress } from '@/components/ui/progress'
|
||||
import { toast } from 'sonner'
|
||||
import { CheckCircle, AlertCircle, Loader2, ChevronLeft, ChevronRight, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
|
||||
// Country list for country select special field
|
||||
const COUNTRIES = [
|
||||
'Afghanistan', 'Albania', 'Algeria', 'Andorra', 'Angola', 'Argentina', 'Armenia', 'Australia',
|
||||
'Austria', 'Azerbaijan', 'Bahamas', 'Bahrain', 'Bangladesh', 'Barbados', 'Belarus', 'Belgium',
|
||||
'Belize', 'Benin', 'Bhutan', 'Bolivia', 'Bosnia and Herzegovina', 'Botswana', 'Brazil', 'Brunei',
|
||||
'Bulgaria', 'Burkina Faso', 'Burundi', 'Cambodia', 'Cameroon', 'Canada', 'Cape Verde',
|
||||
'Central African Republic', 'Chad', 'Chile', 'China', 'Colombia', 'Comoros', 'Congo', 'Costa Rica',
|
||||
'Croatia', 'Cuba', 'Cyprus', 'Czech Republic', 'Denmark', 'Djibouti', 'Dominica', 'Dominican Republic',
|
||||
'Ecuador', 'Egypt', 'El Salvador', 'Equatorial Guinea', 'Eritrea', 'Estonia', 'Eswatini', 'Ethiopia',
|
||||
'Fiji', 'Finland', 'France', 'Gabon', 'Gambia', 'Georgia', 'Germany', 'Ghana', 'Greece', 'Grenada',
|
||||
'Guatemala', 'Guinea', 'Guinea-Bissau', 'Guyana', 'Haiti', 'Honduras', 'Hungary', 'Iceland', 'India',
|
||||
'Indonesia', 'Iran', 'Iraq', 'Ireland', 'Israel', 'Italy', 'Jamaica', 'Japan', 'Jordan', 'Kazakhstan',
|
||||
'Kenya', 'Kiribati', 'Kuwait', 'Kyrgyzstan', 'Laos', 'Latvia', 'Lebanon', 'Lesotho', 'Liberia',
|
||||
'Libya', 'Liechtenstein', 'Lithuania', 'Luxembourg', 'Madagascar', 'Malawi', 'Malaysia', 'Maldives',
|
||||
'Mali', 'Malta', 'Marshall Islands', 'Mauritania', 'Mauritius', 'Mexico', 'Micronesia', 'Moldova',
|
||||
'Monaco', 'Mongolia', 'Montenegro', 'Morocco', 'Mozambique', 'Myanmar', 'Namibia', 'Nauru', 'Nepal',
|
||||
'Netherlands', 'New Zealand', 'Nicaragua', 'Niger', 'Nigeria', 'North Korea', 'North Macedonia',
|
||||
'Norway', 'Oman', 'Pakistan', 'Palau', 'Palestine', 'Panama', 'Papua New Guinea', 'Paraguay', 'Peru',
|
||||
'Philippines', 'Poland', 'Portugal', 'Qatar', 'Romania', 'Russia', 'Rwanda', 'Saint Kitts and Nevis',
|
||||
'Saint Lucia', 'Saint Vincent and the Grenadines', 'Samoa', 'San Marino', 'Sao Tome and Principe',
|
||||
'Saudi Arabia', 'Senegal', 'Serbia', 'Seychelles', 'Sierra Leone', 'Singapore', 'Slovakia', 'Slovenia',
|
||||
'Solomon Islands', 'Somalia', 'South Africa', 'South Korea', 'South Sudan', 'Spain', 'Sri Lanka',
|
||||
'Sudan', 'Suriname', 'Sweden', 'Switzerland', 'Syria', 'Taiwan', 'Tajikistan', 'Tanzania', 'Thailand',
|
||||
'Timor-Leste', 'Togo', 'Tonga', 'Trinidad and Tobago', 'Tunisia', 'Turkey', 'Turkmenistan', 'Tuvalu',
|
||||
'Uganda', 'Ukraine', 'United Arab Emirates', 'United Kingdom', 'United States', 'Uruguay', 'Uzbekistan',
|
||||
'Vanuatu', 'Vatican City', 'Venezuela', 'Vietnam', 'Yemen', 'Zambia', 'Zimbabwe',
|
||||
]
|
||||
|
||||
// Ocean issues for special field
|
||||
const OCEAN_ISSUES = [
|
||||
{ value: 'POLLUTION_REDUCTION', label: 'Pollution Reduction' },
|
||||
{ value: 'CLIMATE_MITIGATION', label: 'Climate Mitigation' },
|
||||
{ value: 'TECHNOLOGY_INNOVATION', label: 'Technology Innovation' },
|
||||
{ value: 'SUSTAINABLE_SHIPPING', label: 'Sustainable Shipping' },
|
||||
{ value: 'BLUE_CARBON', label: 'Blue Carbon' },
|
||||
{ value: 'HABITAT_RESTORATION', label: 'Habitat Restoration' },
|
||||
{ value: 'COMMUNITY_CAPACITY', label: 'Community Capacity Building' },
|
||||
{ value: 'SUSTAINABLE_FISHING', label: 'Sustainable Fishing' },
|
||||
{ value: 'CONSUMER_AWARENESS', label: 'Consumer Awareness' },
|
||||
{ value: 'OCEAN_ACIDIFICATION', label: 'Ocean Acidification' },
|
||||
{ value: 'OTHER', label: 'Other' },
|
||||
]
|
||||
|
||||
// Competition categories for special field
|
||||
const COMPETITION_CATEGORIES = [
|
||||
{ value: 'STARTUP', label: 'Startup - Existing company with traction' },
|
||||
{ value: 'BUSINESS_CONCEPT', label: 'Business Concept - Student/graduate project' },
|
||||
]
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
||||
type FieldType = {
|
||||
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: unknown
|
||||
conditionJson: unknown
|
||||
width: string
|
||||
specialType?: string | null
|
||||
projectMapping?: string | null
|
||||
}
|
||||
|
||||
type StepType = {
|
||||
id: string
|
||||
name: string
|
||||
title: string
|
||||
description?: string | null
|
||||
isOptional: boolean
|
||||
fields: FieldType[]
|
||||
}
|
||||
|
||||
export default function OnboardingWizardPage({ params }: PageProps) {
|
||||
const { slug } = use(params)
|
||||
const router = useRouter()
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [confirmationMessage, setConfirmationMessage] = useState<string | null>(null)
|
||||
|
||||
// Fetch onboarding config
|
||||
const { data: config, isLoading, error } = trpc.onboarding.getConfig.useQuery(
|
||||
{ slug },
|
||||
{ retry: false }
|
||||
)
|
||||
|
||||
// Form state
|
||||
const { control, handleSubmit, watch, setValue, formState: { errors }, trigger } = useForm({
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const watchedValues = watch()
|
||||
|
||||
// Submit mutation
|
||||
const submitMutation = trpc.onboarding.submit.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setSubmitted(true)
|
||||
setConfirmationMessage(result.confirmationMessage || null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message || 'Submission failed')
|
||||
},
|
||||
})
|
||||
|
||||
const steps = config?.steps || []
|
||||
const currentStep = steps[currentStepIndex]
|
||||
const isLastStep = currentStepIndex === steps.length - 1
|
||||
const progress = ((currentStepIndex + 1) / steps.length) * 100
|
||||
|
||||
// Navigate between steps
|
||||
const goToNextStep = async () => {
|
||||
// Validate current step fields
|
||||
const currentFields = currentStep?.fields || []
|
||||
const fieldNames = currentFields.map((f) => f.name)
|
||||
const isValid = await trigger(fieldNames)
|
||||
|
||||
if (!isValid) {
|
||||
toast.error('Please fill in all required fields')
|
||||
return
|
||||
}
|
||||
|
||||
if (isLastStep) {
|
||||
// Submit the form
|
||||
const allData = watchedValues
|
||||
await submitMutation.mutateAsync({
|
||||
formId: config!.form.id,
|
||||
contactName: allData.contactName || allData.name || '',
|
||||
contactEmail: allData.contactEmail || allData.email || '',
|
||||
contactPhone: allData.contactPhone || allData.phone,
|
||||
projectName: allData.projectName || allData.title || '',
|
||||
description: allData.description,
|
||||
competitionCategory: allData.competitionCategory,
|
||||
oceanIssue: allData.oceanIssue,
|
||||
country: allData.country,
|
||||
institution: allData.institution,
|
||||
teamName: allData.teamName,
|
||||
wantsMentorship: allData.wantsMentorship,
|
||||
referralSource: allData.referralSource,
|
||||
foundedAt: allData.foundedAt,
|
||||
teamMembers: allData.teamMembers,
|
||||
metadata: allData,
|
||||
gdprConsent: allData.gdprConsent || false,
|
||||
})
|
||||
} else {
|
||||
setCurrentStepIndex((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPrevStep = () => {
|
||||
setCurrentStepIndex((prev) => Math.max(0, prev - 1))
|
||||
}
|
||||
|
||||
// Render field based on type and special type
|
||||
const renderField = (field: FieldType) => {
|
||||
const errorMessage = errors[field.name]?.message as string | undefined
|
||||
|
||||
// Handle special field types
|
||||
if (field.specialType) {
|
||||
switch (field.specialType) {
|
||||
case 'COMPETITION_CATEGORY':
|
||||
return (
|
||||
<div key={field.id} className="space-y-3">
|
||||
<Label>
|
||||
Competition Category
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{ required: field.required ? 'Please select a category' : false }}
|
||||
render={({ field: f }) => (
|
||||
<RadioGroup value={f.value} onValueChange={f.onChange} className="space-y-3">
|
||||
{COMPETITION_CATEGORIES.map((cat) => (
|
||||
<div
|
||||
key={cat.value}
|
||||
className={cn(
|
||||
'flex items-start space-x-3 p-4 rounded-lg border cursor-pointer transition-colors',
|
||||
f.value === cat.value ? 'border-primary bg-primary/5' : 'hover:bg-muted'
|
||||
)}
|
||||
onClick={() => f.onChange(cat.value)}
|
||||
>
|
||||
<RadioGroupItem value={cat.value} id={cat.value} className="mt-0.5" />
|
||||
<Label htmlFor={cat.value} className="font-normal cursor-pointer">
|
||||
{cat.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'OCEAN_ISSUE':
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Label>
|
||||
Ocean Issue Focus
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{ required: field.required ? 'Please select an ocean issue' : false }}
|
||||
render={({ field: f }) => (
|
||||
<Select value={f.value} onValueChange={f.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select the primary ocean issue your project addresses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OCEAN_ISSUES.map((issue) => (
|
||||
<SelectItem key={issue.value} value={issue.value}>
|
||||
{issue.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'COUNTRY_SELECT':
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Label>
|
||||
{field.label || 'Country'}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{ required: field.required ? 'Please select a country' : false }}
|
||||
render={({ field: f }) => (
|
||||
<Select value={f.value} onValueChange={f.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select country" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COUNTRIES.map((country) => (
|
||||
<SelectItem key={country} value={country}>
|
||||
{country}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'GDPR_CONSENT':
|
||||
return (
|
||||
<div key={field.id} className="space-y-4">
|
||||
<div className="p-4 bg-muted rounded-lg text-sm">
|
||||
<p className="font-medium mb-2">Terms & Conditions</p>
|
||||
<p className="text-muted-foreground">
|
||||
By submitting this application, you agree to our terms of service and privacy policy.
|
||||
Your data will be processed in accordance with GDPR regulations.
|
||||
</p>
|
||||
</div>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value) => value === true || 'You must accept the terms and conditions'
|
||||
}}
|
||||
render={({ field: f }) => (
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={field.name}
|
||||
checked={f.value || false}
|
||||
onCheckedChange={f.onChange}
|
||||
/>
|
||||
<Label htmlFor={field.name} className="font-normal leading-tight cursor-pointer">
|
||||
I accept the terms and conditions and consent to the processing of my data
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Standard field types
|
||||
switch (field.fieldType) {
|
||||
case 'TEXT':
|
||||
case 'EMAIL':
|
||||
case 'PHONE':
|
||||
case 'URL':
|
||||
return (
|
||||
<div key={field.id} className={cn('space-y-2', field.width === 'half' && 'col-span-1')}>
|
||||
<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">{field.description}</p>
|
||||
)}
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
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,
|
||||
pattern: field.fieldType === 'EMAIL' ? { value: /^\S+@\S+$/i, message: 'Invalid email address' } : undefined,
|
||||
}}
|
||||
render={({ field: f }) => (
|
||||
<Input
|
||||
id={field.name}
|
||||
type={field.fieldType === 'EMAIL' ? 'email' : field.fieldType === 'URL' ? 'url' : 'text'}
|
||||
placeholder={field.placeholder || undefined}
|
||||
value={f.value || ''}
|
||||
onChange={f.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'TEXTAREA':
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<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">{field.description}</p>
|
||||
)}
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
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,
|
||||
}}
|
||||
render={({ field: f }) => (
|
||||
<Textarea
|
||||
id={field.name}
|
||||
placeholder={field.placeholder || undefined}
|
||||
rows={4}
|
||||
value={f.value || ''}
|
||||
onChange={f.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'SELECT':
|
||||
const options = (field.optionsJson as Array<{ value: string; label: string }>) || []
|
||||
return (
|
||||
<div key={field.id} className={cn('space-y-2', field.width === 'half' && 'col-span-1')}>
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{ required: field.required ? `${field.label} is required` : false }}
|
||||
render={({ field: f }) => (
|
||||
<Select value={f.value} onValueChange={f.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.placeholder || 'Select an option'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'CHECKBOX':
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: field.required
|
||||
? (value) => value === true || `${field.label} is required`
|
||||
: undefined,
|
||||
}}
|
||||
render={({ field: f }) => (
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={field.name}
|
||||
checked={f.value || false}
|
||||
onCheckedChange={f.onChange}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor={field.name} className="font-normal cursor-pointer">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'DATE':
|
||||
return (
|
||||
<div key={field.id} className={cn('space-y-2', field.width === 'half' && 'col-span-1')}>
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{ required: field.required ? `${field.label} is required` : false }}
|
||||
render={({ field: f }) => (
|
||||
<Input
|
||||
id={field.name}
|
||||
type="date"
|
||||
value={f.value || ''}
|
||||
onChange={f.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
|
||||
<div className="max-w-2xl mx-auto px-4 py-12">
|
||||
<div className="flex justify-center mb-8">
|
||||
<Logo showText />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3].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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background flex items-center justify-center px-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<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">Application Not Available</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
{error.message}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background flex items-center justify-center px-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="rounded-full bg-green-100 p-3 mb-4">
|
||||
<CheckCircle className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Application Submitted!</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
{confirmationMessage || 'Thank you for your submission. We will review your application and get back to you soon.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!config || steps.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background flex items-center justify-center px-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Form Not Configured</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
This application form has not been configured yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
|
||||
<div className="max-w-2xl mx-auto px-4 py-12">
|
||||
{/* Header */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<Logo showText />
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
|
||||
<span>Step {currentStepIndex + 1} of {steps.length}</span>
|
||||
<span>{Math.round(progress)}% complete</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="flex justify-between mt-4">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium transition-colors',
|
||||
index < currentStepIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: index === currentStepIndex
|
||||
? 'bg-primary text-primary-foreground ring-4 ring-primary/20'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{index < currentStepIndex ? <Check className="h-4 w-4" /> : index + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{currentStep?.title}</CardTitle>
|
||||
{currentStep?.description && (
|
||||
<CardDescription>{currentStep.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={(e) => { e.preventDefault(); goToNextStep(); }}>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{currentStep?.fields.map((field) => (
|
||||
<div key={field.id} className={cn(field.width === 'half' ? '' : 'col-span-full')}>
|
||||
{renderField(field)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between border-t pt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={goToPrevStep}
|
||||
disabled={currentStepIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={goToNextStep}
|
||||
disabled={submitMutation.isPending}
|
||||
>
|
||||
{submitMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : isLastStep ? (
|
||||
<>
|
||||
Submit Application
|
||||
<Check className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Next
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground mt-8">
|
||||
{config.program?.name} {config.program?.year && `${config.program.year}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
import { FileText, Calendar, ArrowRight, ExternalLink } from 'lucide-react'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ApplyLandingPage() {
|
||||
// Get all published, public application forms
|
||||
const forms = await prisma.applicationForm.findMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
isPublic: true,
|
||||
OR: [
|
||||
{ opensAt: null },
|
||||
{ opensAt: { lte: new Date() } },
|
||||
],
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ closesAt: null },
|
||||
{ closesAt: { gte: new Date() } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
publicSlug: true,
|
||||
opensAt: true,
|
||||
closesAt: true,
|
||||
steps: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// If exactly one form is available, redirect to it
|
||||
if (forms.length === 1 && forms[0].publicSlug) {
|
||||
const form = forms[0]
|
||||
const hasSteps = form.steps && form.steps.length > 0
|
||||
const url = hasSteps
|
||||
? `/apply/${form.publicSlug}/wizard`
|
||||
: `/apply/${form.publicSlug}`
|
||||
redirect(url as Route)
|
||||
}
|
||||
|
||||
// If no forms are available, show a message
|
||||
if (forms.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white dark:from-slate-950 dark:to-slate-900">
|
||||
<div className="container max-w-2xl py-16">
|
||||
<div className="text-center mb-12">
|
||||
<Logo variant="long" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<FileText className="h-16 w-16 text-muted-foreground/30 mb-6" />
|
||||
<h1 className="text-2xl font-semibold mb-3">Applications Not Open</h1>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
There are currently no open applications. Please check back later
|
||||
or visit our website for more information.
|
||||
</p>
|
||||
<Button asChild className="mt-8">
|
||||
<a href="https://monaco-opc.com" target="_blank" rel="noopener noreferrer">
|
||||
Visit Website
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Multiple forms available - show selection
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white dark:from-slate-950 dark:to-slate-900">
|
||||
<div className="container max-w-4xl py-16">
|
||||
<div className="text-center mb-12">
|
||||
<Logo variant="long" />
|
||||
<h1 className="text-3xl font-bold mt-8 mb-3">Apply Now</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
|
||||
Select an application form below to get started.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{forms.map((form) => {
|
||||
const hasSteps = form.steps && form.steps.length > 0
|
||||
const url = hasSteps
|
||||
? `/apply/${form.publicSlug}/wizard`
|
||||
: `/apply/${form.publicSlug}`
|
||||
|
||||
return (
|
||||
<Card key={form.id} className="overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<Link href={url as Route} className="block">
|
||||
<div className="flex items-stretch">
|
||||
<div className="flex-1 p-6">
|
||||
<CardHeader className="p-0 pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
{form.name}
|
||||
</CardTitle>
|
||||
{form.description && (
|
||||
<CardDescription className="line-clamp-2">
|
||||
{form.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{(form.opensAt || form.closesAt) && (
|
||||
<div className="flex items-center gap-4 mt-4 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{form.closesAt && (
|
||||
<span>
|
||||
Closes: {new Date(form.closesAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center px-6 bg-muted/30 border-l">
|
||||
<Button variant="ghost" size="icon" className="rounded-full">
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-12">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Having trouble? Contact us at{' '}
|
||||
<a href="mailto:support@monaco-opc.com" className="text-primary hover:underline">
|
||||
support@monaco-opc.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export function SubmissionDetailClient() {
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{project.roundProjects?.[0]?.round?.program?.year ? `${project.roundProjects[0].round.program.year} Edition` : ''}{project.roundProjects?.[0]?.round?.name ? ` - ${project.roundProjects[0].round.name}` : ''}
|
||||
{project.round?.program?.year ? `${project.round.program.year} Edition` : ''}{project.round?.name ? ` - ${project.round.name}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -132,10 +132,9 @@ export function MySubmissionClient() {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{submissions.map((project) => {
|
||||
const latestRoundProject = project.roundProjects?.[0]
|
||||
const projectStatus = latestRoundProject?.status ?? 'SUBMITTED'
|
||||
const roundName = latestRoundProject?.round?.name
|
||||
const programYear = latestRoundProject?.round?.program?.year
|
||||
const projectStatus = project.status ?? 'SUBMITTED'
|
||||
const roundName = project.round?.name
|
||||
const programYear = project.round?.program?.year
|
||||
|
||||
return (
|
||||
<Card key={project.id}>
|
||||
|
||||
Reference in New Issue
Block a user