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

@@ -69,7 +69,7 @@ import {
import { cn } from '@/lib/utils'
type Step = 'input' | 'preview' | 'sending' | 'complete'
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
type Role = 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
interface Assignment {
projectId: string
@@ -99,6 +99,7 @@ interface ParsedUser {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const ROLE_LABELS: Record<Role, string> = {
PROGRAM_ADMIN: 'Program Admin',
JURY_MEMBER: 'Jury Member',
MENTOR: 'Mentor',
OBSERVER: 'Observer',
@@ -265,6 +266,11 @@ export default function MemberInvitePage() {
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const utils = trpc.useUtils()
// Fetch current user to check role
const { data: currentUser } = trpc.user.me.useQuery()
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
const bulkCreate = trpc.user.bulkCreate.useMutation({
onSuccess: () => {
// Invalidate user list to refresh the members table when navigating back
@@ -393,19 +399,22 @@ export default function MemberInvitePage() {
const name = nameKey ? row[nameKey]?.trim() : undefined
const rawRole = roleKey ? row[roleKey]?.trim().toUpperCase() : ''
const role: Role =
rawRole === 'MENTOR'
rawRole === 'PROGRAM_ADMIN'
? 'PROGRAM_ADMIN'
: rawRole === 'MENTOR'
? 'MENTOR'
: rawRole === 'OBSERVER'
? 'OBSERVER'
: 'JURY_MEMBER'
const isValidFormat = emailRegex.test(email)
const isDuplicate = email ? seenEmails.has(email) : false
const isUnauthorizedAdmin = role === 'PROGRAM_ADMIN' && !isSuperAdmin
if (isValidFormat && !isDuplicate && email) seenEmails.add(email)
return {
email,
name,
role,
isValid: isValidFormat && !isDuplicate,
isValid: isValidFormat && !isDuplicate && !isUnauthorizedAdmin,
isDuplicate,
error: !email
? 'No email found'
@@ -413,6 +422,8 @@ export default function MemberInvitePage() {
? 'Invalid email format'
: isDuplicate
? 'Duplicate email'
: isUnauthorizedAdmin
? 'Only super admins can invite program admins'
: undefined,
}
})
@@ -421,7 +432,7 @@ export default function MemberInvitePage() {
},
})
},
[]
[isSuperAdmin]
)
// --- Parse manual rows into ParsedUser format ---
@@ -433,6 +444,7 @@ export default function MemberInvitePage() {
const email = r.email.trim().toLowerCase()
const isValidFormat = emailRegex.test(email)
const isDuplicate = seenEmails.has(email)
const isUnauthorizedAdmin = r.role === 'PROGRAM_ADMIN' && !isSuperAdmin
if (isValidFormat && !isDuplicate) seenEmails.add(email)
return {
email,
@@ -440,12 +452,14 @@ export default function MemberInvitePage() {
role: r.role,
expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined,
assignments: r.assignments.length > 0 ? r.assignments : undefined,
isValid: isValidFormat && !isDuplicate,
isValid: isValidFormat && !isDuplicate && !isUnauthorizedAdmin,
isDuplicate,
error: !isValidFormat
? 'Invalid email format'
: isDuplicate
? 'Duplicate email'
: isUnauthorizedAdmin
? 'Only super admins can invite program admins'
: undefined,
}
})
@@ -524,6 +538,11 @@ export default function MemberInvitePage() {
<CardTitle>Invite Members</CardTitle>
<CardDescription>
Add members individually or upload a CSV file
{isSuperAdmin && (
<span className="block mt-1 text-primary font-medium">
As a super admin, you can also invite program admins
</span>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@@ -627,6 +646,11 @@ export default function MemberInvitePage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
{isSuperAdmin && (
<SelectItem value="PROGRAM_ADMIN">
Program Admin
</SelectItem>
)}
<SelectItem value="JURY_MEMBER">
Jury Member
</SelectItem>

File diff suppressed because it is too large Load Diff

View File

@@ -45,18 +45,23 @@ export default function EditProgramPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
name: '',
slug: '',
description: '',
status: 'DRAFT',
applyMode: 'round' as 'edition' | 'round' | 'both',
})
const { data: program, isLoading } = trpc.program.get.useQuery({ id })
useEffect(() => {
if (program) {
const settings = (program.settingsJson as Record<string, any>) || {}
setFormData({
name: program.name,
slug: program.slug || '',
description: program.description || '',
status: program.status,
applyMode: settings.applyMode || 'round',
})
}
}, [program])
@@ -89,8 +94,12 @@ export default function EditProgramPage() {
updateProgram.mutate({
id,
name: formData.name,
slug: formData.slug || undefined,
description: formData.description || undefined,
status: formData.status as 'DRAFT' | 'ACTIVE' | 'ARCHIVED',
settingsJson: {
applyMode: formData.applyMode,
},
})
}
@@ -196,6 +205,41 @@ export default function EditProgramPage() {
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="slug">Edition Slug</Label>
<Input
id="slug"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
placeholder="e.g., mopc-2026"
/>
<p className="text-xs text-muted-foreground">
URL-friendly identifier for edition-wide applications (optional)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="applyMode">Application Flow</Label>
<Select
value={formData.applyMode}
onValueChange={(value) => setFormData({ ...formData, applyMode: value as 'edition' | 'round' | 'both' })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="edition">Edition-wide only</SelectItem>
<SelectItem value="round">Round-specific only</SelectItem>
<SelectItem value="both">Allow both</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Controls whether applicants apply to the program or specific rounds
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea

View File

@@ -33,6 +33,7 @@ import {
FolderKanban,
Eye,
Pencil,
Wand2,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
@@ -146,6 +147,12 @@ async function ProgramsContent() {
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/programs/${program.id}/apply-settings`}>
<Wand2 className="mr-2 h-4 w-4" />
Apply Settings
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
@@ -194,6 +201,12 @@ async function ProgramsContent() {
Edit
</Link>
</Button>
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/programs/${program.id}/apply-settings`}>
<Wand2 className="mr-2 h-4 w-4" />
Apply
</Link>
</Button>
</div>
</CardContent>
</Card>

View File

@@ -78,9 +78,21 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
id: projectId,
})
// Fetch files
// Fetch files (flat list for backward compatibility)
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
// Fetch grouped files by round (if project has a roundId)
const { data: groupedFiles } = trpc.file.listByProjectForRound.useQuery(
{ projectId, roundId: project?.roundId || '' },
{ enabled: !!project?.roundId }
)
// Fetch available rounds for upload selector (if project has a programId)
const { data: rounds } = trpc.round.listByProgram.useQuery(
{ programId: project?.programId || '' },
{ enabled: !!project?.programId }
)
// Fetch assignments
const { data: assignments } = trpc.assignment.listByProject.useQuery({
projectId,
@@ -492,7 +504,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{files && files.length > 0 ? (
{groupedFiles && groupedFiles.length > 0 ? (
<FileViewer groupedFiles={groupedFiles} />
) : files && files.length > 0 ? (
<FileViewer
projectId={projectId}
files={files.map((f) => ({
@@ -516,8 +530,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<p className="text-sm font-medium mb-3">Upload New Files</p>
<FileUpload
projectId={projectId}
roundId={project.roundId || undefined}
availableRounds={rounds?.map((r: { id: string; name: string }) => ({ id: r.id, name: r.name }))}
onUploadComplete={() => {
utils.file.listByProject.invalidate({ projectId })
if (project.roundId) {
utils.file.listByProjectForRound.invalidate({ projectId, roundId: project.roundId })
}
}}
/>
</div>

View File

@@ -0,0 +1,345 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import Link from 'next/link'
export default function ProjectPoolPage() {
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
const [targetRoundId, setTargetRoundId] = useState<string>('')
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
const [currentPage, setCurrentPage] = useState(1)
const perPage = 50
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
{
programId: selectedProgramId,
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
search: searchQuery || undefined,
page: currentPage,
perPage,
},
{ enabled: !!selectedProgramId }
)
const { data: rounds, isLoading: isLoadingRounds } = trpc.round.listByProgram.useQuery(
{ programId: selectedProgramId },
{ enabled: !!selectedProgramId }
)
const assignMutation = trpc.projectPool.assignToRound.useMutation({
onSuccess: (result) => {
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
setSelectedProjects([])
setAssignDialogOpen(false)
setTargetRoundId('')
refetch()
},
onError: (error) => {
toast.error(error.message || 'Failed to assign projects')
},
})
const handleBulkAssign = () => {
if (selectedProjects.length === 0 || !targetRoundId) return
assignMutation.mutate({
projectIds: selectedProjects,
roundId: targetRoundId,
})
}
const handleQuickAssign = (projectId: string, roundId: string) => {
assignMutation.mutate({
projectIds: [projectId],
roundId,
})
}
const toggleSelectAll = () => {
if (!poolData?.projects) return
if (selectedProjects.length === poolData.projects.length) {
setSelectedProjects([])
} else {
setSelectedProjects(poolData.projects.map((p) => p.id))
}
}
const toggleSelectProject = (projectId: string) => {
if (selectedProjects.includes(projectId)) {
setSelectedProjects(selectedProjects.filter((id) => id !== projectId))
} else {
setSelectedProjects([...selectedProjects, projectId])
}
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold">Project Pool</h1>
<p className="text-muted-foreground">
Assign unassigned projects to evaluation rounds
</p>
</div>
{/* Program Selector */}
<Card className="p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-end">
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Program</label>
<Select value={selectedProgramId} onValueChange={(value) => {
setSelectedProgramId(value)
setSelectedProjects([])
setCurrentPage(1)
}}>
<SelectTrigger>
<SelectValue placeholder="Select program..." />
</SelectTrigger>
<SelectContent>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Category</label>
<Select value={categoryFilter} onValueChange={(value: any) => {
setCategoryFilter(value)
setCurrentPage(1)
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Search</label>
<Input
placeholder="Project or team name..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setCurrentPage(1)
}}
/>
</div>
{selectedProjects.length > 0 && (
<Button onClick={() => setAssignDialogOpen(true)} className="whitespace-nowrap">
Assign {selectedProjects.length} Project{selectedProjects.length > 1 ? 's' : ''}
</Button>
)}
</div>
</Card>
{/* Projects Table */}
{selectedProgramId && (
<>
{isLoadingPool ? (
<Card className="p-4">
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</Card>
) : poolData && poolData.total > 0 ? (
<>
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b">
<tr className="text-sm">
<th className="p-3 text-left">
<Checkbox
checked={selectedProjects.length === poolData.projects.length && poolData.projects.length > 0}
onCheckedChange={toggleSelectAll}
/>
</th>
<th className="p-3 text-left font-medium">Project</th>
<th className="p-3 text-left font-medium">Category</th>
<th className="p-3 text-left font-medium">Country</th>
<th className="p-3 text-left font-medium">Submitted</th>
<th className="p-3 text-left font-medium">Action</th>
</tr>
</thead>
<tbody>
{poolData.projects.map((project) => (
<tr key={project.id} className="border-b hover:bg-muted/50">
<td className="p-3">
<Checkbox
checked={selectedProjects.includes(project.id)}
onCheckedChange={() => toggleSelectProject(project.id)}
/>
</td>
<td className="p-3">
<Link
href={`/admin/projects/${project.id}`}
className="hover:underline"
>
<div className="font-medium">{project.title}</div>
<div className="text-sm text-muted-foreground">{project.teamName}</div>
</Link>
</td>
<td className="p-3">
<Badge variant="outline">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
</td>
<td className="p-3 text-sm text-muted-foreground">
{project.country || '-'}
</td>
<td className="p-3 text-sm text-muted-foreground">
{project.submittedAt
? new Date(project.submittedAt).toLocaleDateString()
: '-'}
</td>
<td className="p-3">
{isLoadingRounds ? (
<Skeleton className="h-9 w-[200px]" />
) : (
<Select
onValueChange={(roundId) => handleQuickAssign(project.id, roundId)}
disabled={assignMutation.isPending}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Assign to round..." />
</SelectTrigger>
<SelectContent>
{rounds?.map((round: { id: string; name: string; sortOrder: number }) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* Pagination */}
{poolData.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {((currentPage - 1) * perPage) + 1} to {Math.min(currentPage * perPage, poolData.total)} of {poolData.total} projects
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === poolData.totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
) : (
<Card className="p-8 text-center text-muted-foreground">
No unassigned projects found for this program
</Card>
)}
</>
)}
{!selectedProgramId && (
<Card className="p-8 text-center text-muted-foreground">
Select a program to view unassigned projects
</Card>
)}
{/* Bulk Assignment Dialog */}
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign Projects to Round</DialogTitle>
<DialogDescription>
Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to:
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select round..." />
</SelectTrigger>
<SelectContent>
{rounds?.map((round: { id: string; name: string; sortOrder: number }) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAssignDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleBulkAssign}
disabled={!targetRoundId || assignMutation.isPending}
>
{assignMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -9,6 +9,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
import { ArrowLeft, AlertCircle, Clock, FileText, Users } from 'lucide-react'
import { isFuture, isPast } from 'date-fns'
@@ -76,6 +77,9 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
where: { id: projectId },
include: {
files: true,
_count: {
select: { files: true },
},
},
})
@@ -266,6 +270,13 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
</Card>
)}
{/* Project Files */}
<CollapsibleFilesSection
projectId={project.id}
roundId={round.id}
fileCount={project._count?.files || 0}
/>
{/* Evaluation Form with COI Gate */}
<EvaluationFormWithCOI
assignmentId={assignment.id}

View File

@@ -16,7 +16,8 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { FileViewer, FileViewerSkeleton } from '@/components/shared/file-viewer'
import { FileViewerSkeleton } from '@/components/shared/file-viewer'
import { ProjectFilesSection } from '@/components/jury/project-files-section'
import {
ArrowLeft,
ArrowRight,
@@ -255,7 +256,9 @@ async function ProjectContent({ projectId }: { projectId: string }) {
</Card>
{/* Files */}
<FileViewer files={project.files} />
<Suspense fallback={<FileViewerSkeleton />}>
<ProjectFilesSection projectId={project.id} roundId={assignment.roundId} />
</Suspense>
</div>
{/* Sidebar */}

View File

@@ -1,423 +1,65 @@
'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 { ApplyWizardDynamic } from '@/components/forms/apply-wizard-dynamic'
import { Loader2, AlertCircle } 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'
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
import { toast } from 'sonner'
// 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() {
export default function RoundApplyPage() {
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 },
{ slug, mode: 'round' },
{ retry: false }
)
const submitMutation = trpc.application.submit.useMutation({
onSuccess: (result) => {
setSubmitted(true)
setSubmissionMessage(result.message)
},
onError: (error) => {
toast.error(error.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 className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
// Error state
if (error) {
if (error || !config || config.mode !== 'round') {
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">
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
<div className="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>
<p className="text-muted-foreground mb-6">{error?.message ?? 'Not found'}</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>
<ApplyWizardDynamic
mode="round"
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
programName={config.program.name}
programYear={config.program.year}
roundId={config.round.id}
isOpen={config.round.isOpen}
submissionDeadline={config.round.submissionEndDate}
onSubmit={async (data) => {
await submitMutation.mutateAsync({
mode: 'round',
roundId: config.round.id,
data: data as any,
})
}}
isSubmitting={submitMutation.isPending}
/>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { ApplyWizardDynamic } from '@/components/forms/apply-wizard-dynamic'
import { Loader2, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
import { toast } from 'sonner'
export default function EditionApplyPage() {
const params = useParams()
const router = useRouter()
const programSlug = params.programSlug as string
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
{ slug: programSlug, mode: 'edition' },
{ retry: false }
)
const submitMutation = trpc.application.submit.useMutation({
onError: (error) => toast.error(error.message),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
if (error || !config || config.mode !== 'edition') {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
<div className="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 ?? 'Not found'}</p>
<Button variant="outline" onClick={() => router.push('/')}>Return Home</Button>
</div>
</div>
)
}
return (
<ApplyWizardDynamic
mode="edition"
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
programName={config.program.name}
programYear={config.program.year}
programId={config.program.id}
isOpen={config.program.isOpen}
submissionDeadline={config.program.submissionEndDate}
onSubmit={async (data) => {
await submitMutation.mutateAsync({
mode: 'edition',
programId: config.program.id,
data: data as any,
})
}}
isSubmitting={submitMutation.isPending}
/>
)
}