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:
2026-02-08 13:18:20 +01:00
parent 98fe658c33
commit e7c86a7b1b
40 changed files with 4477 additions and 1045 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,76 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { ChevronDown, ChevronUp, FileText } from 'lucide-react'
import { ProjectFilesSection } from './project-files-section'
interface CollapsibleFilesSectionProps {
projectId: string
roundId: string
fileCount: number
}
export function CollapsibleFilesSection({
projectId,
roundId,
fileCount,
}: CollapsibleFilesSectionProps) {
const [isExpanded, setIsExpanded] = useState(false)
const [showFiles, setShowFiles] = useState(false)
const handleToggle = () => {
setIsExpanded(!isExpanded)
// Lazy-load the files when expanding for the first time
if (!isExpanded && !showFiles) {
setShowFiles(true)
}
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<h3 className="font-semibold text-lg">
Project Documents ({fileCount})
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleToggle}
aria-label={isExpanded ? 'Collapse documents' : 'Expand documents'}
className="gap-1"
>
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4" />
<span className="text-sm">Hide</span>
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
<span className="text-sm">Show</span>
</>
)}
</Button>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="pt-0">
{showFiles ? (
<ProjectFilesSection projectId={projectId} roundId={roundId} />
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
Loading documents...
</div>
)}
</CardContent>
)}
</Card>
)
}

View File

@@ -0,0 +1,92 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { FileViewer } from '@/components/shared/file-viewer'
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertCircle, FileX } from 'lucide-react'
interface ProjectFilesSectionProps {
projectId: string
roundId: string
}
export function ProjectFilesSection({ projectId, roundId }: ProjectFilesSectionProps) {
const { data: groupedFiles, isLoading, error } = trpc.file.listByProjectForRound.useQuery({
projectId,
roundId,
})
if (isLoading) {
return <ProjectFilesSectionSkeleton />
}
if (error) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium text-destructive">Failed to load files</p>
<p className="text-sm text-muted-foreground">
{error.message || 'An error occurred while loading project files'}
</p>
</CardContent>
</Card>
)
}
if (!groupedFiles || groupedFiles.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<FileX className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No files available</p>
<p className="text-sm text-muted-foreground">
This project has no files uploaded yet
</p>
</CardContent>
</Card>
)
}
// Flatten all files from all round groups for FileViewer
const allFiles = groupedFiles.flatMap((group) => group.files)
return (
<div className="space-y-4">
{groupedFiles.map((group) => (
<div key={group.roundId || 'general'} className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
{group.roundName}
</h3>
<div className="flex-1 h-px bg-border" />
</div>
<FileViewer files={group.files} />
</div>
))}
</div>
)
}
function ProjectFilesSectionSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border p-3">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-9 w-20" />
</div>
))}
</CardContent>
</Card>
)
}

View File

@@ -34,6 +34,7 @@ import {
User,
LayoutTemplate,
MessageSquare,
Wand2,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
@@ -111,6 +112,11 @@ const adminNavigation = [
href: '/admin/programs' as const,
icon: FolderKanban,
},
{
name: 'Apply Settings',
href: '/admin/programs' as const,
icon: Wand2,
},
{
name: 'Audit Log',
href: '/admin/audit' as const,

View File

@@ -46,6 +46,8 @@ interface FileUploadProps {
allowedTypes?: string[]
multiple?: boolean
className?: string
roundId?: string
availableRounds?: Array<{ id: string; name: string }>
}
// Map MIME types to suggested file types
@@ -83,9 +85,12 @@ export function FileUpload({
allowedTypes,
multiple = true,
className,
roundId,
availableRounds,
}: FileUploadProps) {
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(roundId ?? null)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.file.getUploadUrl.useMutation()
@@ -124,6 +129,7 @@ export function FileUpload({
fileType,
mimeType: file.type || 'application/octet-stream',
size: file.size,
roundId: selectedRoundId ?? undefined,
})
// Store the DB file ID
@@ -303,6 +309,31 @@ export function FileUpload({
return (
<div className={cn('space-y-4', className)}>
{/* Round selector */}
{availableRounds && availableRounds.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium">
Upload for Round
</label>
<Select
value={selectedRoundId ?? 'null'}
onValueChange={(value) => setSelectedRoundId(value === 'null' ? null : value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
<SelectItem value="null">General (no specific round)</SelectItem>
{availableRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Drop zone */}
<div
className={cn(

View File

@@ -39,10 +39,19 @@ interface ProjectFile {
bucket: string
objectKey: string
version?: number
isLate?: boolean
}
interface RoundGroup {
roundId: string | null
roundName: string
sortOrder: number
files: Array<ProjectFile & { isLate?: boolean }>
}
interface FileViewerProps {
files: ProjectFile[]
files?: ProjectFile[]
groupedFiles?: RoundGroup[]
projectId?: string
className?: string
}
@@ -83,8 +92,14 @@ function getFileTypeLabel(fileType: string) {
}
}
export function FileViewer({ files, projectId, className }: FileViewerProps) {
if (files.length === 0) {
export function FileViewer({ files, groupedFiles, projectId, className }: FileViewerProps) {
// Render grouped view if groupedFiles is provided
if (groupedFiles) {
return <GroupedFileViewer groupedFiles={groupedFiles} className={className} />
}
// Render flat view (backward compatible)
if (!files || files.length === 0) {
return (
<Card className={className}>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
@@ -121,6 +136,68 @@ export function FileViewer({ files, projectId, className }: FileViewerProps) {
)
}
function GroupedFileViewer({ groupedFiles, className }: { groupedFiles: RoundGroup[], className?: string }) {
const hasAnyFiles = groupedFiles.some(group => group.files.length > 0)
if (!hasAnyFiles) {
return (
<Card className={className}>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<File className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No files attached</p>
<p className="text-sm text-muted-foreground">
This project has no files uploaded yet
</p>
</CardContent>
</Card>
)
}
// Sort groups by sortOrder
const sortedGroups = [...groupedFiles].sort((a, b) => a.sortOrder - b.sortOrder)
// Sort files within each group by type order
const fileTypeSortOrder = ['EXEC_SUMMARY', 'BUSINESS_PLAN', 'PRESENTATION', 'VIDEO', 'VIDEO_PITCH', 'SUPPORTING_DOC', 'OTHER']
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-lg">Project Files</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{sortedGroups.map((group) => {
if (group.files.length === 0) return null
const sortedFiles = [...group.files].sort(
(a, b) => fileTypeSortOrder.indexOf(a.fileType) - fileTypeSortOrder.indexOf(b.fileType)
)
return (
<div key={group.roundId || 'no-round'} className="space-y-3">
{/* Round header */}
<div className="flex items-center justify-between border-b pb-2">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
{group.roundName}
</h3>
<Badge variant="outline" className="text-xs">
{group.files.length} {group.files.length === 1 ? 'file' : 'files'}
</Badge>
</div>
{/* Files in this round */}
<div className="space-y-3">
{sortedFiles.map((file) => (
<FileItem key={file.id} file={file} />
))}
</div>
</div>
)
})}
</CardContent>
</Card>
)
}
function FileItem({ file }: { file: ProjectFile }) {
const [showPreview, setShowPreview] = useState(false)
const Icon = getFileIcon(file.fileType, file.mimeType)
@@ -151,10 +228,15 @@ function FileItem({ file }: { file: ProjectFile }) {
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<Badge variant="secondary" className="text-xs">
{getFileTypeLabel(file.fileType)}
</Badge>
{file.isLate && (
<Badge variant="destructive" className="text-xs">
Late
</Badge>
)}
<span>{formatFileSize(file.size)}</span>
</div>
</div>
@@ -489,7 +571,7 @@ function FilePreview({ file, url }: { file: ProjectFile; url: string }) {
// Compact file list for smaller views
export function FileList({ files, className }: FileViewerProps) {
if (files.length === 0) return null
if (!files || files.length === 0) return null
return (
<div className={cn('space-y-2', className)}>