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:
2026-02-04 14:15:06 +01:00
parent 7bcd2ce6ca
commit 29827268b2
71 changed files with 2139 additions and 6609 deletions

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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}>