Add dynamic apply wizard customization with admin settings UI
- Create wizard config types, utilities, and defaults (wizard-config.ts) - Add admin apply settings page with drag-and-drop step ordering, dropdown option management, feature toggles, welcome message customization, and custom field builder with select/multiselect options editor - Build dynamic apply wizard component with animated step transitions, mobile-first responsive design, and config-driven form validation - Update step components to accept dynamic config (categories, ocean issues, field visibility, feature flags) - Replace hardcoded enum validation with string-based validation for admin-configurable dropdown values, with safe enum casting at storage layer - Add wizard template system (model, router, admin UI) with built-in MOPC Classic preset - Add program wizard config CRUD procedures to program router - Update application router getConfig to return wizardConfig, submit handler to store custom field data in metadataJson - Add edition-based apply page, project pool page, and supporting routers - Fix CSS (invalid sm:fixed-none), Enter key handler (skip textarea), safe area insets for notched phones, buildStepsArray field visibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,14 +8,16 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import type { WizardConfig } from '@/types/wizard-config'
|
||||
|
||||
interface StepAdditionalProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
isBusinessConcept: boolean
|
||||
isStartup: boolean
|
||||
config?: WizardConfig
|
||||
}
|
||||
|
||||
export function StepAdditional({ form, isBusinessConcept, isStartup }: StepAdditionalProps) {
|
||||
export function StepAdditional({ form, isBusinessConcept, isStartup, config }: StepAdditionalProps) {
|
||||
const { register, formState: { errors }, setValue, watch } = form
|
||||
const wantsMentorship = watch('wantsMentorship')
|
||||
|
||||
@@ -86,34 +88,36 @@ export function StepAdditional({ form, isBusinessConcept, isStartup }: StepAddit
|
||||
)}
|
||||
|
||||
{/* Mentorship */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="rounded-lg border bg-card p-6"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<Heart className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="wantsMentorship" className="text-base font-medium">
|
||||
Would you like mentorship support?
|
||||
</Label>
|
||||
<Switch
|
||||
id="wantsMentorship"
|
||||
checked={wantsMentorship}
|
||||
onCheckedChange={(checked) => setValue('wantsMentorship', checked)}
|
||||
/>
|
||||
{config?.features?.enableMentorship !== false && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="rounded-lg border bg-card p-6"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<Heart className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="wantsMentorship" className="text-base font-medium">
|
||||
Would you like mentorship support?
|
||||
</Label>
|
||||
<Switch
|
||||
id="wantsMentorship"
|
||||
checked={wantsMentorship}
|
||||
onCheckedChange={(checked) => setValue('wantsMentorship', checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Our mentors are industry experts who can help guide your project.
|
||||
This is optional but highly recommended.
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Our mentors are industry experts who can help guide your project.
|
||||
This is optional but highly recommended.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Referral Source */}
|
||||
<motion.div
|
||||
|
||||
@@ -8,16 +8,27 @@ import { Label } from '@/components/ui/label'
|
||||
import { PhoneInput } from '@/components/ui/phone-input'
|
||||
import { CountrySelect } from '@/components/ui/country-select'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import type { WizardConfig } from '@/types/wizard-config'
|
||||
import { isFieldVisible, isFieldRequired, getFieldConfig } from '@/lib/wizard-config'
|
||||
|
||||
interface StepContactProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
config?: WizardConfig
|
||||
}
|
||||
|
||||
export function StepContact({ form }: StepContactProps) {
|
||||
export function StepContact({ form, config }: StepContactProps) {
|
||||
const { register, formState: { errors }, setValue, watch } = form
|
||||
const country = watch('country')
|
||||
const phone = watch('contactPhone')
|
||||
|
||||
const showPhone = !config || isFieldVisible(config, 'contactPhone')
|
||||
const showCountry = !config || isFieldVisible(config, 'country')
|
||||
const showCity = !config || isFieldVisible(config, 'city')
|
||||
const phoneRequired = !config || isFieldRequired(config, 'contactPhone')
|
||||
const countryRequired = !config || isFieldRequired(config, 'country')
|
||||
const phoneLabel = config ? getFieldConfig(config, 'contactPhone').label : undefined
|
||||
const countryLabel = config ? getFieldConfig(config, 'country').label : undefined
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="Tell us about yourself"
|
||||
@@ -62,49 +73,57 @@ export function StepContact({ form }: StepContactProps) {
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactPhone">
|
||||
Phone Number <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<PhoneInput
|
||||
value={phone}
|
||||
onChange={(value) => setValue('contactPhone', value || '')}
|
||||
defaultCountry="MC"
|
||||
className="h-12"
|
||||
/>
|
||||
{errors.contactPhone && (
|
||||
<p className="text-sm text-destructive">{errors.contactPhone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{showPhone && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactPhone">
|
||||
{phoneLabel ?? 'Phone Number'}{' '}
|
||||
{phoneRequired ? <span className="text-destructive">*</span> : <span className="text-muted-foreground text-xs">(optional)</span>}
|
||||
</Label>
|
||||
<PhoneInput
|
||||
value={phone}
|
||||
onChange={(value) => setValue('contactPhone', value || '')}
|
||||
defaultCountry="MC"
|
||||
className="h-12"
|
||||
/>
|
||||
{errors.contactPhone && (
|
||||
<p className="text-sm text-destructive">{errors.contactPhone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Country */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Country <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<CountrySelect
|
||||
value={country}
|
||||
onChange={(value) => setValue('country', value)}
|
||||
placeholder="Select your country"
|
||||
className="h-12"
|
||||
/>
|
||||
{errors.country && (
|
||||
<p className="text-sm text-destructive">{errors.country.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{showCountry && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{countryLabel ?? 'Country'}{' '}
|
||||
{countryRequired ? <span className="text-destructive">*</span> : <span className="text-muted-foreground text-xs">(optional)</span>}
|
||||
</Label>
|
||||
<CountrySelect
|
||||
value={country}
|
||||
onChange={(value) => setValue('country', value)}
|
||||
placeholder="Select your country"
|
||||
className="h-12"
|
||||
/>
|
||||
{errors.country && (
|
||||
<p className="text-sm text-destructive">{errors.country.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* City (optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">
|
||||
City <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="city"
|
||||
placeholder="Monaco"
|
||||
{...register('city')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
{showCity && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">
|
||||
City <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="city"
|
||||
placeholder="Monaco"
|
||||
{...register('city')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
|
||||
@@ -14,35 +14,22 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import { OceanIssue } from '@prisma/client'
|
||||
|
||||
interface OceanIssueOption {
|
||||
value: OceanIssue
|
||||
label: string
|
||||
}
|
||||
|
||||
const oceanIssueOptions: OceanIssueOption[] = [
|
||||
{ value: 'POLLUTION_REDUCTION', label: 'Reduction of pollution (plastics, chemicals, noise, light,...)' },
|
||||
{ value: 'CLIMATE_MITIGATION', label: 'Mitigation of climate change and sea-level rise' },
|
||||
{ value: 'TECHNOLOGY_INNOVATION', label: 'Technology & innovations' },
|
||||
{ value: 'SUSTAINABLE_SHIPPING', label: 'Sustainable shipping & yachting' },
|
||||
{ value: 'BLUE_CARBON', label: 'Blue carbon' },
|
||||
{ value: 'HABITAT_RESTORATION', label: 'Restoration of marine habitats & ecosystems' },
|
||||
{ value: 'COMMUNITY_CAPACITY', label: 'Capacity building for coastal communities' },
|
||||
{ value: 'SUSTAINABLE_FISHING', label: 'Sustainable fishing and aquaculture & blue food' },
|
||||
{ value: 'CONSUMER_AWARENESS', label: 'Consumer awareness and education' },
|
||||
{ value: 'OCEAN_ACIDIFICATION', label: 'Mitigation of ocean acidification' },
|
||||
{ value: 'OTHER', label: 'Other' },
|
||||
]
|
||||
import { type DropdownOption, type WizardConfig, DEFAULT_OCEAN_ISSUES } from '@/types/wizard-config'
|
||||
import { isFieldVisible, getFieldConfig } from '@/lib/wizard-config'
|
||||
|
||||
interface StepProjectProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
oceanIssues?: DropdownOption[]
|
||||
config?: WizardConfig
|
||||
}
|
||||
|
||||
export function StepProject({ form }: StepProjectProps) {
|
||||
export function StepProject({ form, oceanIssues, config }: StepProjectProps) {
|
||||
const issueOptions = oceanIssues ?? DEFAULT_OCEAN_ISSUES
|
||||
const { register, formState: { errors }, setValue, watch } = form
|
||||
const oceanIssue = watch('oceanIssue')
|
||||
const description = watch('description') || ''
|
||||
const showTeamName = !config || isFieldVisible(config, 'teamName')
|
||||
const descriptionLabel = config ? getFieldConfig(config, 'description').label : undefined
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
@@ -71,17 +58,19 @@ export function StepProject({ form }: StepProjectProps) {
|
||||
</div>
|
||||
|
||||
{/* Team Name (optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="teamName">
|
||||
Team Name <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="teamName"
|
||||
placeholder="Blue Innovation Team"
|
||||
{...register('teamName')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
{showTeamName && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="teamName">
|
||||
Team Name <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="teamName"
|
||||
placeholder="Blue Innovation Team"
|
||||
{...register('teamName')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ocean Issue */}
|
||||
<div className="space-y-2">
|
||||
@@ -90,13 +79,13 @@ export function StepProject({ form }: StepProjectProps) {
|
||||
</Label>
|
||||
<Select
|
||||
value={oceanIssue}
|
||||
onValueChange={(value) => setValue('oceanIssue', value as OceanIssue)}
|
||||
onValueChange={(value) => setValue('oceanIssue', value)}
|
||||
>
|
||||
<SelectTrigger className="h-12 text-base">
|
||||
<SelectValue placeholder="Select an ocean issue" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{oceanIssueOptions.map((option) => (
|
||||
{issueOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
|
||||
@@ -22,37 +22,30 @@ import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import { countries } from '@/components/ui/country-select'
|
||||
|
||||
const oceanIssueLabels: Record<string, string> = {
|
||||
POLLUTION_REDUCTION: 'Reduction of pollution',
|
||||
CLIMATE_MITIGATION: 'Climate change mitigation',
|
||||
TECHNOLOGY_INNOVATION: 'Technology & innovations',
|
||||
SUSTAINABLE_SHIPPING: 'Sustainable shipping & yachting',
|
||||
BLUE_CARBON: 'Blue carbon',
|
||||
HABITAT_RESTORATION: 'Marine habitat restoration',
|
||||
COMMUNITY_CAPACITY: 'Coastal community capacity',
|
||||
SUSTAINABLE_FISHING: 'Sustainable fishing & aquaculture',
|
||||
CONSUMER_AWARENESS: 'Consumer awareness & education',
|
||||
OCEAN_ACIDIFICATION: 'Ocean acidification mitigation',
|
||||
OTHER: 'Other',
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
BUSINESS_CONCEPT: 'Business Concepts',
|
||||
STARTUP: 'Start-ups',
|
||||
}
|
||||
import { type WizardConfig, DEFAULT_OCEAN_ISSUES, DEFAULT_COMPETITION_CATEGORIES } from '@/types/wizard-config'
|
||||
|
||||
interface StepReviewProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
programName: string
|
||||
config?: WizardConfig
|
||||
}
|
||||
|
||||
export function StepReview({ form, programName }: StepReviewProps) {
|
||||
export function StepReview({ form, programName, config }: StepReviewProps) {
|
||||
const { formState: { errors }, setValue, watch } = form
|
||||
const data = watch()
|
||||
|
||||
const countryName = countries.find((c) => c.code === data.country)?.name || data.country
|
||||
|
||||
const getOceanIssueLabel = (value: string): string => {
|
||||
const issues = config?.oceanIssues ?? DEFAULT_OCEAN_ISSUES
|
||||
return issues.find((i) => i.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
const getCategoryLabel = (value: string): string => {
|
||||
const cats = config?.competitionCategories ?? DEFAULT_COMPETITION_CATEGORIES
|
||||
return cats.find((c) => c.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="Review your application"
|
||||
@@ -108,12 +101,12 @@ export function StepReview({ form, programName }: StepReviewProps) {
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{categoryLabels[data.competitionCategory]}
|
||||
{getCategoryLabel(data.competitionCategory)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Waves className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{oceanIssueLabels[data.oceanIssue]}</span>
|
||||
<span>{getOceanIssueLabel(data.oceanIssue)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Description:</span>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import { TeamMemberRole } from '@prisma/client'
|
||||
import type { WizardConfig } from '@/types/wizard-config'
|
||||
|
||||
const roleOptions: { value: TeamMemberRole; label: string }[] = [
|
||||
{ value: 'MEMBER', label: 'Team Member' },
|
||||
@@ -25,6 +26,7 @@ const roleOptions: { value: TeamMemberRole; label: string }[] = [
|
||||
|
||||
interface StepTeamProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
config?: WizardConfig
|
||||
}
|
||||
|
||||
export function StepTeam({ form }: StepTeamProps) {
|
||||
|
||||
@@ -1,41 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { Waves, Rocket, GraduationCap } from 'lucide-react'
|
||||
import { Waves, Rocket, GraduationCap, type LucideIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { CompetitionCategory } from '@prisma/client'
|
||||
import { type DropdownOption, type WelcomeMessage, DEFAULT_COMPETITION_CATEGORIES } from '@/types/wizard-config'
|
||||
|
||||
interface CategoryOption {
|
||||
value: CompetitionCategory
|
||||
label: string
|
||||
description: string
|
||||
icon: typeof Rocket
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
GraduationCap,
|
||||
Rocket,
|
||||
}
|
||||
|
||||
const categories: CategoryOption[] = [
|
||||
{
|
||||
value: 'BUSINESS_CONCEPT',
|
||||
label: 'Business Concepts',
|
||||
description: 'For students and recent graduates with innovative ocean-focused business ideas',
|
||||
icon: GraduationCap,
|
||||
},
|
||||
{
|
||||
value: 'STARTUP',
|
||||
label: 'Start-ups',
|
||||
description: 'For established companies working on ocean protection solutions',
|
||||
icon: Rocket,
|
||||
},
|
||||
]
|
||||
|
||||
interface StepWelcomeProps {
|
||||
programName: string
|
||||
programYear: number
|
||||
value: CompetitionCategory | null
|
||||
onChange: (value: CompetitionCategory) => void
|
||||
value: string | null
|
||||
onChange: (value: string) => void
|
||||
categories?: DropdownOption[]
|
||||
welcomeMessage?: WelcomeMessage
|
||||
}
|
||||
|
||||
export function StepWelcome({ programName, programYear, value, onChange }: StepWelcomeProps) {
|
||||
export function StepWelcome({ programName, programYear, value, onChange, categories, welcomeMessage }: StepWelcomeProps) {
|
||||
const categoryOptions = categories ?? DEFAULT_COMPETITION_CATEGORIES
|
||||
|
||||
return (
|
||||
<WizardStepContent>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
@@ -58,13 +45,13 @@ export function StepWelcome({ programName, programYear, value, onChange }: StepW
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
|
||||
{programName}
|
||||
{welcomeMessage?.title ?? programName}
|
||||
</h1>
|
||||
<p className="mt-2 text-xl text-primary font-semibold">
|
||||
{programYear} Application
|
||||
</p>
|
||||
<p className="mt-4 max-w-md text-muted-foreground">
|
||||
Join us in protecting our oceans. Select your category to begin.
|
||||
{welcomeMessage?.description ?? 'Join us in protecting our oceans. Select your category to begin.'}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -75,8 +62,8 @@ export function StepWelcome({ programName, programYear, value, onChange }: StepW
|
||||
transition={{ delay: 0.4 }}
|
||||
className="mt-10 grid w-full max-w-2xl gap-4 md:grid-cols-2"
|
||||
>
|
||||
{categories.map((category) => {
|
||||
const Icon = category.icon
|
||||
{categoryOptions.map((category) => {
|
||||
const Icon = (category.icon ? ICON_MAP[category.icon] : undefined) ?? Waves
|
||||
const isSelected = value === category.value
|
||||
|
||||
return (
|
||||
|
||||
806
src/components/forms/apply-wizard-dynamic.tsx
Normal file
806
src/components/forms/apply-wizard-dynamic.tsx
Normal file
@@ -0,0 +1,806 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm, UseFormReturn } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import {
|
||||
Waves,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
StepWelcome,
|
||||
StepContact,
|
||||
StepProject,
|
||||
StepTeam,
|
||||
StepAdditional,
|
||||
StepReview,
|
||||
} from './apply-steps'
|
||||
import type { WizardConfig, WizardStepId, CustomField } from '@/types/wizard-config'
|
||||
import {
|
||||
getVisibleSteps,
|
||||
isFieldVisible,
|
||||
isFieldRequired,
|
||||
buildStepsArray,
|
||||
getCustomFieldsForStep,
|
||||
} from '@/lib/wizard-config'
|
||||
import { TeamMemberRole } from '@prisma/client'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ApplyWizardDynamicProps {
|
||||
mode: 'edition' | 'round'
|
||||
config: WizardConfig
|
||||
programName: string
|
||||
programYear: number
|
||||
programId?: string
|
||||
roundId?: string
|
||||
isOpen: boolean
|
||||
submissionDeadline?: Date | string | null
|
||||
onSubmit: (data: Record<string, unknown>) => Promise<void>
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animation variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const variants = {
|
||||
enter: (dir: number) => ({ x: dir > 0 ? 50 : -50, opacity: 0 }),
|
||||
center: { x: 0, opacity: 1 },
|
||||
exit: (dir: number) => ({ x: dir < 0 ? 50 : -50, opacity: 0 }),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom field renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CustomFieldRenderer({
|
||||
field,
|
||||
form,
|
||||
}: {
|
||||
field: CustomField
|
||||
form: UseFormReturn<Record<string, unknown>>
|
||||
}) {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
watch,
|
||||
} = form
|
||||
|
||||
const value = watch(field.id)
|
||||
const error = errors[field.id]
|
||||
|
||||
const labelEl = (
|
||||
<Label htmlFor={field.id}>
|
||||
{field.label}
|
||||
{field.required ? (
|
||||
<span className="text-destructive"> *</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs ml-1">(optional)</span>
|
||||
)}
|
||||
</Label>
|
||||
)
|
||||
|
||||
const helpEl = field.helpText ? (
|
||||
<p className="text-xs text-muted-foreground">{field.helpText}</p>
|
||||
) : null
|
||||
|
||||
const errorEl = error ? (
|
||||
<p className="text-sm text-destructive">{error.message as string}</p>
|
||||
) : null
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{labelEl}
|
||||
{helpEl}
|
||||
<Input
|
||||
id={field.id}
|
||||
placeholder={field.placeholder}
|
||||
{...register(field.id)}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errorEl}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{labelEl}
|
||||
{helpEl}
|
||||
<Textarea
|
||||
id={field.id}
|
||||
placeholder={field.placeholder}
|
||||
rows={4}
|
||||
{...register(field.id)}
|
||||
className="text-base resize-none"
|
||||
/>
|
||||
{errorEl}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{labelEl}
|
||||
{helpEl}
|
||||
<Input
|
||||
id={field.id}
|
||||
type="number"
|
||||
placeholder={field.placeholder}
|
||||
{...register(field.id)}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errorEl}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{labelEl}
|
||||
{helpEl}
|
||||
<Select
|
||||
value={(value as string) ?? ''}
|
||||
onValueChange={(v) => setValue(field.id, v)}
|
||||
>
|
||||
<SelectTrigger className="h-12 text-base">
|
||||
<SelectValue placeholder={field.placeholder ?? 'Select...'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.options ?? []).map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errorEl}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'multiselect': {
|
||||
const selected: string[] = Array.isArray(value) ? (value as string[]) : []
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{labelEl}
|
||||
{helpEl}
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
{(field.options ?? []).map((opt) => {
|
||||
const checked = selected.includes(opt)
|
||||
return (
|
||||
<div key={opt} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`${field.id}-${opt}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(c) => {
|
||||
if (c) {
|
||||
setValue(field.id, [...selected, opt])
|
||||
} else {
|
||||
setValue(
|
||||
field.id,
|
||||
selected.filter((s) => s !== opt)
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`${field.id}-${opt}`} className="text-sm font-normal">
|
||||
{opt}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{errorEl}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={field.id}
|
||||
checked={value === true}
|
||||
onCheckedChange={(c) => setValue(field.id, c === true)}
|
||||
/>
|
||||
<Label htmlFor={field.id} className="text-sm font-normal">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
</div>
|
||||
{helpEl}
|
||||
{errorEl}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{labelEl}
|
||||
{helpEl}
|
||||
<Input
|
||||
id={field.id}
|
||||
type="date"
|
||||
{...register(field.id)}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errorEl}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dynamic schema builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildDynamicSchema(config: WizardConfig): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
||||
const shape: Record<string, z.ZodTypeAny> = {}
|
||||
|
||||
// Always required
|
||||
shape.competitionCategory = z.string().min(1, 'Competition category is required')
|
||||
shape.gdprConsent = z.boolean().refine((val) => val === true, {
|
||||
message: 'You must agree to the data processing terms',
|
||||
})
|
||||
|
||||
// Contact fields
|
||||
if (isFieldVisible(config, 'contactName')) {
|
||||
shape.contactName = isFieldRequired(config, 'contactName')
|
||||
? z.string().min(2, 'Full name is required')
|
||||
: z.string().optional()
|
||||
} else {
|
||||
shape.contactName = z.string().optional()
|
||||
}
|
||||
|
||||
if (isFieldVisible(config, 'contactEmail')) {
|
||||
shape.contactEmail = isFieldRequired(config, 'contactEmail')
|
||||
? z.string().email('Invalid email address')
|
||||
: z.string().optional()
|
||||
} else {
|
||||
shape.contactEmail = z.string().optional()
|
||||
}
|
||||
|
||||
if (isFieldVisible(config, 'contactPhone')) {
|
||||
shape.contactPhone = isFieldRequired(config, 'contactPhone')
|
||||
? z.string().min(5, 'Phone number is required')
|
||||
: z.string().optional()
|
||||
} else {
|
||||
shape.contactPhone = z.string().optional()
|
||||
}
|
||||
|
||||
if (isFieldVisible(config, 'country')) {
|
||||
shape.country = isFieldRequired(config, 'country')
|
||||
? z.string().min(2, 'Country is required')
|
||||
: z.string().optional()
|
||||
} else {
|
||||
shape.country = z.string().optional()
|
||||
}
|
||||
|
||||
shape.city = z.string().optional()
|
||||
|
||||
// Project fields
|
||||
if (isFieldVisible(config, 'projectName')) {
|
||||
shape.projectName = isFieldRequired(config, 'projectName')
|
||||
? z.string().min(2, 'Project name is required').max(200)
|
||||
: z.string().optional()
|
||||
} else {
|
||||
shape.projectName = z.string().optional()
|
||||
}
|
||||
|
||||
shape.teamName = z.string().optional()
|
||||
|
||||
if (isFieldVisible(config, 'description')) {
|
||||
shape.description = isFieldRequired(config, 'description')
|
||||
? z.string().min(20, 'Description must be at least 20 characters')
|
||||
: z.string().optional()
|
||||
} else {
|
||||
shape.description = z.string().optional()
|
||||
}
|
||||
|
||||
if (isFieldVisible(config, 'oceanIssue')) {
|
||||
shape.oceanIssue = isFieldRequired(config, 'oceanIssue')
|
||||
? z.string().min(1, 'Ocean issue is required')
|
||||
: z.string().optional()
|
||||
} else {
|
||||
shape.oceanIssue = z.string().optional()
|
||||
}
|
||||
|
||||
// Team members
|
||||
if (config.features?.enableTeamMembers !== false) {
|
||||
shape.teamMembers = z
|
||||
.array(
|
||||
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(),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
// Additional fields - always optional at schema level
|
||||
shape.institution = z.string().optional()
|
||||
shape.startupCreatedDate = z.string().optional()
|
||||
shape.wantsMentorship = z.boolean().default(false)
|
||||
shape.referralSource = z.string().optional()
|
||||
|
||||
// Custom fields
|
||||
for (const cf of config.customFields ?? []) {
|
||||
if (cf.required) {
|
||||
if (cf.type === 'checkbox') {
|
||||
shape[cf.id] = z.boolean().refine((v) => v === true, { message: `${cf.label} is required` })
|
||||
} else if (cf.type === 'multiselect') {
|
||||
shape[cf.id] = z.array(z.string()).min(1, `${cf.label} is required`)
|
||||
} else {
|
||||
shape[cf.id] = z.string().min(1, `${cf.label} is required`)
|
||||
}
|
||||
} else {
|
||||
if (cf.type === 'checkbox') {
|
||||
shape[cf.id] = z.boolean().optional()
|
||||
} else if (cf.type === 'multiselect') {
|
||||
shape[cf.id] = z.array(z.string()).optional()
|
||||
} else {
|
||||
shape[cf.id] = z.string().optional()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return z.object(shape)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ApplyWizardDynamic({
|
||||
mode,
|
||||
config,
|
||||
programName,
|
||||
programYear,
|
||||
programId,
|
||||
roundId,
|
||||
isOpen,
|
||||
submissionDeadline,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: ApplyWizardDynamicProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [direction, setDirection] = useState(0)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [submissionMessage, setSubmissionMessage] = useState('')
|
||||
|
||||
// Build dynamic schema from config
|
||||
const schema = useMemo(() => buildDynamicSchema(config), [config])
|
||||
|
||||
// Build default values
|
||||
const defaultValues = useMemo(() => {
|
||||
const defaults: Record<string, unknown> = {
|
||||
competitionCategory: undefined,
|
||||
contactName: '',
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
country: '',
|
||||
city: '',
|
||||
projectName: '',
|
||||
teamName: '',
|
||||
description: '',
|
||||
oceanIssue: undefined,
|
||||
teamMembers: [],
|
||||
institution: '',
|
||||
startupCreatedDate: '',
|
||||
wantsMentorship: false,
|
||||
referralSource: '',
|
||||
gdprConsent: false,
|
||||
}
|
||||
|
||||
// Add defaults for custom fields
|
||||
for (const cf of config.customFields ?? []) {
|
||||
if (cf.type === 'checkbox') {
|
||||
defaults[cf.id] = false
|
||||
} else if (cf.type === 'multiselect') {
|
||||
defaults[cf.id] = []
|
||||
} else {
|
||||
defaults[cf.id] = ''
|
||||
}
|
||||
}
|
||||
|
||||
return defaults
|
||||
}, [config])
|
||||
|
||||
const form = useForm<Record<string, unknown>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues,
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const { watch, trigger, handleSubmit } = form
|
||||
const formValues = watch()
|
||||
const competitionCategory = formValues.competitionCategory as string | undefined
|
||||
|
||||
const isBusinessConcept = competitionCategory === 'BUSINESS_CONCEPT'
|
||||
const isStartup = competitionCategory === 'STARTUP'
|
||||
|
||||
// Visible steps from config
|
||||
const visibleSteps = useMemo(
|
||||
() => getVisibleSteps(config, formValues as Record<string, unknown>),
|
||||
[config, formValues]
|
||||
)
|
||||
|
||||
// Steps array for validation mapping
|
||||
const stepsArray = useMemo(() => buildStepsArray(config), [config])
|
||||
|
||||
// Filtered steps array matching visible step IDs
|
||||
const activeSteps = useMemo(() => {
|
||||
const visibleIds = new Set(visibleSteps.map((s) => s.id as string))
|
||||
return stepsArray.filter((s) => visibleIds.has(s.id))
|
||||
}, [stepsArray, visibleSteps])
|
||||
|
||||
// Validate current step fields
|
||||
const validateCurrentStep = useCallback(async () => {
|
||||
if (currentStep >= activeSteps.length) return true
|
||||
const stepDef = activeSteps[currentStep]
|
||||
const fields = stepDef.fields as string[]
|
||||
|
||||
// Also validate custom fields for this step
|
||||
const customFields = getCustomFieldsForStep(config, stepDef.id as WizardStepId)
|
||||
const customFieldIds = customFields.map((cf) => cf.id)
|
||||
|
||||
const allFields = [...fields, ...customFieldIds]
|
||||
if (allFields.length === 0) return true
|
||||
return await trigger(allFields)
|
||||
}, [currentStep, activeSteps, config, trigger])
|
||||
|
||||
// Navigation
|
||||
const nextStep = useCallback(async () => {
|
||||
const isValid = await validateCurrentStep()
|
||||
if (isValid && currentStep < activeSteps.length - 1) {
|
||||
setDirection(1)
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}, [validateCurrentStep, currentStep, activeSteps.length])
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
if (currentStep > 0) {
|
||||
setDirection(-1)
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}, [currentStep])
|
||||
|
||||
// Handle form submit
|
||||
const handleFormSubmit = useCallback(
|
||||
async (data: Record<string, unknown>) => {
|
||||
try {
|
||||
await onSubmit(data)
|
||||
setSubmitted(true)
|
||||
setSubmissionMessage(
|
||||
'Thank you for your application! You will receive a confirmation email shortly.'
|
||||
)
|
||||
} catch {
|
||||
// Error handled by parent via onSubmit rejection
|
||||
}
|
||||
},
|
||||
[onSubmit]
|
||||
)
|
||||
|
||||
// Keyboard navigation (skip when focused on textarea or contenteditable)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const isTextarea = target.tagName === 'TEXTAREA'
|
||||
const isContentEditable = target.isContentEditable
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!e.shiftKey &&
|
||||
!isTextarea &&
|
||||
!isContentEditable &&
|
||||
currentStep < activeSteps.length - 1
|
||||
) {
|
||||
e.preventDefault()
|
||||
nextStep()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [currentStep, activeSteps.length, nextStep])
|
||||
|
||||
// --- Closed state ---
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background via-background to-primary/5 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 {programName} {programYear} has ended.
|
||||
{submissionDeadline && (
|
||||
<span className="block mt-2">
|
||||
Submissions closed on{' '}
|
||||
{new Date(submissionDeadline).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 via-background to-primary/5 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>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Wizard ---
|
||||
const currentStepDef = activeSteps[currentStep]
|
||||
if (!currentStepDef) return null
|
||||
|
||||
const progress = ((currentStep + 1) / activeSteps.length) * 100
|
||||
const currentStepId = currentStepDef.id as WizardStepId
|
||||
const customFields = getCustomFieldsForStep(config, currentStepId)
|
||||
|
||||
// Render the appropriate step component
|
||||
function renderStep() {
|
||||
switch (currentStepId) {
|
||||
case 'welcome':
|
||||
return (
|
||||
<StepWelcome
|
||||
programName={programName}
|
||||
programYear={programYear}
|
||||
value={competitionCategory as string | null}
|
||||
onChange={(value) => form.setValue('competitionCategory', value)}
|
||||
categories={config.competitionCategories}
|
||||
welcomeMessage={config.welcomeMessage}
|
||||
/>
|
||||
)
|
||||
case 'contact':
|
||||
return <StepContact form={form as UseFormReturn<any>} config={config} />
|
||||
case 'project':
|
||||
return (
|
||||
<StepProject
|
||||
form={form as UseFormReturn<any>}
|
||||
oceanIssues={config.oceanIssues}
|
||||
config={config}
|
||||
/>
|
||||
)
|
||||
case 'team':
|
||||
if (config.features?.enableTeamMembers === false) return null
|
||||
return <StepTeam form={form as UseFormReturn<any>} config={config} />
|
||||
case 'additional':
|
||||
return (
|
||||
<StepAdditional
|
||||
form={form as UseFormReturn<any>}
|
||||
isBusinessConcept={isBusinessConcept}
|
||||
isStartup={isStartup}
|
||||
config={config}
|
||||
/>
|
||||
)
|
||||
case 'review':
|
||||
return (
|
||||
<StepReview
|
||||
form={form as UseFormReturn<any>}
|
||||
programName={`${programName} ${programYear}`}
|
||||
config={config}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background via-background to-primary/5">
|
||||
{/* Sticky 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">
|
||||
{programName} {programYear}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">Application</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Step {currentStep + 1} of {activeSteps.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mt-4 h-1 sm: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 (hidden on mobile) */}
|
||||
<div className="mt-3 hidden sm:flex justify-between">
|
||||
{activeSteps.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(
|
||||
'text-xs font-medium transition-colors',
|
||||
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-3xl px-4 py-8">
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="relative min-h-[400px] sm: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"
|
||||
>
|
||||
{renderStep()}
|
||||
|
||||
{/* Custom fields for this step */}
|
||||
{customFields.length > 0 && (
|
||||
<div className="mt-6 space-y-4 mx-auto max-w-md">
|
||||
{customFields.map((field) => (
|
||||
<CustomFieldRenderer key={field.id} field={field} form={form} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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 || isSubmitting}
|
||||
className={cn(
|
||||
'h-11 sm:h-10',
|
||||
currentStep === 0 && 'invisible'
|
||||
)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{currentStep < activeSteps.length - 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={nextStep}
|
||||
className="h-11 sm:h-10"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="h-11 sm:h-10"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{/* Deadline footer */}
|
||||
{submissionDeadline && (
|
||||
<footer className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:relative sm:static sm:mt-8 sm:pb-3">
|
||||
<div className="mx-auto max-w-3xl px-4 text-center text-sm text-muted-foreground">
|
||||
<Clock className="inline-block mr-1 h-4 w-4" />
|
||||
Applications due by{' '}
|
||||
{new Date(submissionDeadline).toLocaleDateString('en-US', {
|
||||
dateStyle: 'long',
|
||||
})}
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user