Remove dynamic form builder and complete RoundProject→roundId migration

Major cleanup and schema migration:
- Remove unused dynamic form builder system (ApplicationForm, ApplicationFormField, etc.)
- Complete migration from RoundProject junction table to direct Project.roundId
- Add sortOrder and entryNotificationType fields to Round model
- Add country field to User model for mentor matching
- Enhance onboarding with profile photo and country selection steps
- Fix all TypeScript errors related to roundProjects references
- Remove unused libraries (@radix-ui/react-toast, embla-carousel-react, vaul)

Files removed:
- admin/forms/* pages and related components
- admin/onboarding/* pages
- applicationForm.ts and onboarding.ts routers
- Dynamic form builder Prisma models and enums

Schema changes:
- Removed ApplicationForm, ApplicationFormField, OnboardingStep, ApplicationFormSubmission, SubmissionFile models
- Removed FormFieldType and SpecialFieldType enums
- Added Round.sortOrder, Round.entryNotificationType
- Added User.country

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 14:15:06 +01:00
parent 7bcd2ce6ca
commit 29827268b2
71 changed files with 2139 additions and 6609 deletions

View File

@@ -1,241 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Loader2, Plus, Trash2, GripVertical, Save } from 'lucide-react'
import { toast } from 'sonner'
interface FormEditorProps {
form: {
id: string
name: string
description: string | null
status: string
isPublic: boolean
publicSlug: string | null
submissionLimit: number | null
opensAt: Date | null
closesAt: Date | null
confirmationMessage: string | null
fields: Array<{
id: string
fieldType: string
name: string
label: string
description: string | null
placeholder: string | null
required: boolean
sortOrder: number
}>
}
}
export function FormEditor({ form }: FormEditorProps) {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
name: form.name,
description: form.description || '',
status: form.status,
isPublic: form.isPublic,
publicSlug: form.publicSlug || '',
confirmationMessage: form.confirmationMessage || '',
})
const updateForm = trpc.applicationForm.update.useMutation({
onSuccess: () => {
toast.success('Form updated successfully')
router.refresh()
setIsSubmitting(false)
},
onError: (error) => {
toast.error(error.message || 'Failed to update form')
setIsSubmitting(false)
},
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
updateForm.mutate({
id: form.id,
name: formData.name,
status: formData.status as 'DRAFT' | 'PUBLISHED' | 'CLOSED',
isPublic: formData.isPublic,
description: formData.description || null,
publicSlug: formData.publicSlug || null,
confirmationMessage: formData.confirmationMessage || null,
})
}
return (
<Tabs defaultValue="settings" className="space-y-6">
<TabsList>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="fields">Fields ({form.fields.length})</TabsTrigger>
</TabsList>
<TabsContent value="settings">
<Card>
<CardHeader>
<CardTitle>Form Settings</CardTitle>
<CardDescription>
Configure the basic settings for this form
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Form Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={formData.status}
onValueChange={(value) => setFormData({ ...formData, status: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="PUBLISHED">Published</SelectItem>
<SelectItem value="CLOSED">Closed</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
maxLength={2000}
/>
</div>
<div className="space-y-2">
<Label htmlFor="publicSlug">Public URL Slug</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">/apply/</span>
<Input
id="publicSlug"
value={formData.publicSlug}
onChange={(e) => setFormData({ ...formData, publicSlug: e.target.value })}
className="flex-1"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
id="isPublic"
checked={formData.isPublic}
onCheckedChange={(checked) => setFormData({ ...formData, isPublic: checked })}
/>
<Label htmlFor="isPublic">Public form (accessible without login)</Label>
</div>
<div className="space-y-2">
<Label htmlFor="confirmationMessage">Confirmation Message</Label>
<Textarea
id="confirmationMessage"
value={formData.confirmationMessage}
onChange={(e) => setFormData({ ...formData, confirmationMessage: e.target.value })}
placeholder="Thank you for your submission..."
rows={3}
maxLength={1000}
/>
</div>
<div className="flex justify-end pt-4">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
Save Settings
</Button>
</div>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="fields">
<Card>
<CardHeader>
<CardTitle>Form Fields</CardTitle>
<CardDescription>
Add and arrange the fields for your application form
</CardDescription>
</CardHeader>
<CardContent>
{form.fields.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No fields added yet.</p>
<p className="text-sm">Add fields to start building your form.</p>
</div>
) : (
<div className="space-y-2">
{form.fields.map((field) => (
<div
key={field.id}
className="flex items-center gap-3 p-3 border rounded-lg"
>
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
<div className="flex-1">
<div className="font-medium">{field.label}</div>
<div className="text-sm text-muted-foreground">
{field.fieldType} {field.required && '(required)'}
</div>
</div>
<Button variant="ghost" size="icon" aria-label="Delete field">
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
<div className="mt-4">
<Button variant="outline">
<Plus className="mr-2 h-4 w-4" />
Add Field
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
)
}

View File

@@ -1,83 +0,0 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ArrowLeft, Settings, Eye, FileText, Plus } from 'lucide-react'
import { FormEditor } from './form-editor'
interface FormDetailPageProps {
params: Promise<{ id: string }>
}
export default async function FormDetailPage({ params }: FormDetailPageProps) {
const { id } = await params
const caller = await api()
let form
try {
form = await caller.applicationForm.get({ id })
} catch {
notFound()
}
const statusColors = {
DRAFT: 'bg-gray-100 text-gray-800',
PUBLISHED: 'bg-green-100 text-green-800',
CLOSED: 'bg-red-100 text-red-800',
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/forms">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">{form.name}</h1>
<Badge className={statusColors[form.status as keyof typeof statusColors]}>
{form.status}
</Badge>
</div>
<p className="text-muted-foreground">
{form.fields.length} fields - {form._count.submissions} submissions
</p>
</div>
</div>
<div className="flex items-center gap-2">
{form.publicSlug && form.status === 'PUBLISHED' && (
<a
href={`/apply/${form.publicSlug}`}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline">
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
</a>
)}
<Link href={`/admin/forms/${id}/submissions`}>
<Button variant="outline">
<FileText className="mr-2 h-4 w-4" />
Submissions
</Button>
</Link>
</div>
</div>
<FormEditor form={form} />
</div>
)
}

View File

@@ -1,130 +0,0 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ArrowLeft, Download, Trash2 } from 'lucide-react'
import { formatDate } from '@/lib/utils'
interface SubmissionDetailPageProps {
params: Promise<{ id: string; submissionId: string }>
}
const statusColors = {
SUBMITTED: 'bg-blue-100 text-blue-800',
REVIEWED: 'bg-yellow-100 text-yellow-800',
APPROVED: 'bg-green-100 text-green-800',
REJECTED: 'bg-red-100 text-red-800',
}
export default async function SubmissionDetailPage({ params }: SubmissionDetailPageProps) {
const { id, submissionId } = await params
const caller = await api()
let submission
try {
submission = await caller.applicationForm.getSubmission({ id: submissionId })
} catch {
notFound()
}
const data = submission.dataJson as Record<string, unknown>
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/admin/forms/${id}/submissions`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">
{submission.name || submission.email || 'Anonymous Submission'}
</h1>
<Badge className={statusColors[submission.status as keyof typeof statusColors]}>
{submission.status}
</Badge>
</div>
<p className="text-muted-foreground">
Submitted {formatDate(submission.createdAt)}
</p>
</div>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Submission Data</CardTitle>
<CardDescription>
All fields submitted in this application
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{submission.form.fields.map((field) => {
const value = data[field.name]
return (
<div key={field.id} className="border-b pb-4 last:border-0">
<div className="font-medium text-sm text-muted-foreground">
{field.label}
</div>
<div className="mt-1">
{value !== undefined && value !== null && value !== '' ? (
typeof value === 'object' ? (
<pre className="text-sm bg-muted p-2 rounded">
{JSON.stringify(value, null, 2)}
</pre>
) : (
<span>{String(value)}</span>
)
) : (
<span className="text-muted-foreground italic">Not provided</span>
)}
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
{submission.files.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Attached Files</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{submission.files.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<div className="font-medium">{file.fileName}</div>
<div className="text-sm text-muted-foreground">
{file.size ? `${(file.size / 1024).toFixed(1)} KB` : 'Unknown size'}
</div>
</div>
<Button variant="ghost" size="icon">
<Download className="h-4 w-4" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -1,135 +0,0 @@
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, Inbox, Eye, Download } from 'lucide-react'
import { formatDate } from '@/lib/utils'
interface SubmissionsPageProps {
params: Promise<{ id: string }>
}
const statusColors = {
SUBMITTED: 'bg-blue-100 text-blue-800',
REVIEWED: 'bg-yellow-100 text-yellow-800',
APPROVED: 'bg-green-100 text-green-800',
REJECTED: 'bg-red-100 text-red-800',
}
async function SubmissionsList({ formId }: { formId: string }) {
const caller = await api()
const { data: submissions } = await caller.applicationForm.listSubmissions({
formId,
perPage: 50,
})
if (submissions.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Inbox className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No submissions yet</h3>
<p className="text-muted-foreground">
Submissions will appear here once people start filling out the form
</p>
</CardContent>
</Card>
)
}
return (
<div className="grid gap-4">
{submissions.map((submission) => (
<Card key={submission.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium">
{submission.name || submission.email || 'Anonymous'}
</h3>
<Badge className={statusColors[submission.status as keyof typeof statusColors]}>
{submission.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{submission.email && <span>{submission.email} - </span>}
Submitted {formatDate(submission.createdAt)}
</p>
</div>
<div className="flex items-center gap-2">
<Link href={`/admin/forms/${formId}/submissions/${submission.id}`}>
<Button variant="ghost" size="icon">
<Eye className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default async function SubmissionsPage({ params }: SubmissionsPageProps) {
const { id } = await params
const caller = await api()
let form
try {
form = await caller.applicationForm.get({ id })
} catch {
notFound()
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/admin/forms/${id}`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Submissions</h1>
<p className="text-muted-foreground">
{form.name} - {form._count.submissions} total submissions
</p>
</div>
</div>
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<SubmissionsList formId={id} />
</Suspense>
</div>
)
}

View File

@@ -1,131 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { ArrowLeft, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export default function NewFormPage() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const createForm = trpc.applicationForm.create.useMutation({
onSuccess: (data) => {
toast.success('Form created successfully')
router.push(`/admin/forms/${data.id}`)
},
onError: (error) => {
toast.error(error.message || 'Failed to create form')
setIsSubmitting(false)
},
})
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsSubmitting(true)
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const description = formData.get('description') as string
const publicSlug = formData.get('publicSlug') as string
createForm.mutate({
programId: null,
name,
description: description || undefined,
publicSlug: publicSlug || undefined,
})
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/forms">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Create Application Form</h1>
<p className="text-muted-foreground">
Set up a new application form
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Form Details</CardTitle>
<CardDescription>
Basic information about your application form
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Form Name *</Label>
<Input
id="name"
name="name"
placeholder="e.g., 2024 Project Applications"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
placeholder="Describe the purpose of this form..."
rows={3}
maxLength={2000}
/>
</div>
<div className="space-y-2">
<Label htmlFor="publicSlug">Public URL Slug</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">/apply/</span>
<Input
id="publicSlug"
name="publicSlug"
placeholder="e.g., 2024-applications"
className="flex-1"
/>
</div>
<p className="text-sm text-muted-foreground">
Leave empty to generate automatically. Only lowercase letters, numbers, and hyphens.
</p>
</div>
<div className="flex justify-end gap-2 pt-4">
<Link href="/admin/forms">
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Form
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,152 +0,0 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Plus,
Pencil,
FileText,
ExternalLink,
Inbox,
Copy,
MoreHorizontal,
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
const statusColors = {
DRAFT: 'bg-gray-100 text-gray-800',
PUBLISHED: 'bg-green-100 text-green-800',
CLOSED: 'bg-red-100 text-red-800',
}
async function FormsList() {
const caller = await api()
const { data: forms } = await caller.applicationForm.list({
perPage: 50,
})
if (forms.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No forms yet</h3>
<p className="text-muted-foreground mb-4">
Create your first application form
</p>
<Link href="/admin/forms/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Form
</Button>
</Link>
</CardContent>
</Card>
)
}
return (
<div className="grid gap-4">
{forms.map((form) => (
<Card key={form.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<FileText className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{form.name}</h3>
<Badge className={statusColors[form.status as keyof typeof statusColors]}>
{form.status}
</Badge>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
<span>{form._count.fields} fields</span>
<span>-</span>
<span>{form._count.submissions} submissions</span>
{form.publicSlug && (
<>
<span>-</span>
<span className="text-primary">/apply/{form.publicSlug}</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
{form.publicSlug && form.status === 'PUBLISHED' && (
<a
href={`/apply/${form.publicSlug}`}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="icon" title="View Public Form">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<Link href={`/admin/forms/${form.id}/submissions`}>
<Button variant="ghost" size="icon" title="View Submissions">
<Inbox className="h-4 w-4" />
</Button>
</Link>
<Link href={`/admin/forms/${form.id}`}>
<Button variant="ghost" size="icon" title="Edit Form">
<Pencil className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default function FormsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Application Forms</h1>
<p className="text-muted-foreground">
Create and manage custom application forms
</p>
</div>
<Link href="/admin/forms/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Form
</Button>
</Link>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<FormsList />
</Suspense>
</div>
)
}

View File

@@ -329,7 +329,7 @@ export default function MemberDetailPage() {
</TableCell>
<TableCell>
<Badge variant="secondary">
{assignment.project.roundProjects?.[0]?.status ?? 'SUBMITTED'}
{assignment.project.status ?? 'SUBMITTED'}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">

View File

@@ -31,6 +31,12 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Checkbox } from '@/components/ui/checkbox'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
ArrowLeft,
ArrowRight,
@@ -42,12 +48,19 @@ import {
Plus,
FileSpreadsheet,
UserPlus,
FolderKanban,
ChevronDown,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type Step = 'input' | 'preview' | 'sending' | 'complete'
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
interface Assignment {
projectId: string
roundId: string
}
interface MemberRow {
id: string
name: string
@@ -55,6 +68,7 @@ interface MemberRow {
role: Role
expertiseTags: string[]
tagInput: string
assignments: Assignment[]
}
interface ParsedUser {
@@ -62,6 +76,7 @@ interface ParsedUser {
name?: string
role: Role
expertiseTags?: string[]
assignments?: Assignment[]
isValid: boolean
error?: string
isDuplicate?: boolean
@@ -81,7 +96,7 @@ function nextRowId(): string {
}
function createEmptyRow(role: Role = 'JURY_MEMBER'): MemberRow {
return { id: nextRowId(), name: '', email: '', role, expertiseTags: [], tagInput: '' }
return { id: nextRowId(), name: '', email: '', role, expertiseTags: [], tagInput: '', assignments: [] }
}
// Common expertise tags for suggestions
@@ -115,8 +130,12 @@ export default function MemberInvitePage() {
const [result, setResult] = useState<{
created: number
skipped: number
assignmentsCreated?: number
} | null>(null)
// Pre-assignment state
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const utils = trpc.useUtils()
const bulkCreate = trpc.user.bulkCreate.useMutation({
onSuccess: () => {
@@ -125,6 +144,33 @@ export default function MemberInvitePage() {
},
})
// Fetch programs with rounds for pre-assignment
const { data: programsData } = trpc.program.list.useQuery({
status: 'ACTIVE',
includeRounds: true,
})
// Flatten all rounds from all programs
const rounds = useMemo(() => {
if (!programsData) return []
type ProgramWithRounds = typeof programsData[number] & {
rounds?: Array<{ id: string; name: string }>
}
return (programsData as ProgramWithRounds[]).flatMap((program) =>
(program.rounds || []).map((round) => ({
id: round.id,
name: round.name,
programName: `${program.name} ${program.year}`,
}))
)
}, [programsData])
// Fetch projects for selected round
const { data: projectsData, isLoading: projectsLoading } = trpc.project.list.useQuery(
{ roundId: selectedRoundId, perPage: 200 },
{ enabled: !!selectedRoundId }
)
const projects = projectsData?.projects || []
// --- Manual entry helpers ---
const updateRow = (id: string, field: keyof MemberRow, value: string | string[]) => {
setRows((prev) =>
@@ -176,6 +222,28 @@ export default function MemberInvitePage() {
).slice(0, 5)
}
// Per-row project assignment management
const toggleProjectAssignment = (rowId: string, projectId: string) => {
if (!selectedRoundId) return
setRows((prev) =>
prev.map((r) => {
if (r.id !== rowId) return r
const existing = r.assignments.find((a) => a.projectId === projectId)
if (existing) {
return { ...r, assignments: r.assignments.filter((a) => a.projectId !== projectId) }
} else {
return { ...r, assignments: [...r.assignments, { projectId, roundId: selectedRoundId }] }
}
})
)
}
const clearRowAssignments = (rowId: string) => {
setRows((prev) =>
prev.map((r) => (r.id === rowId ? { ...r, assignments: [] } : r))
)
}
// --- CSV helpers ---
const handleCSVUpload = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -252,6 +320,7 @@ export default function MemberInvitePage() {
name: r.name.trim() || undefined,
role: r.role,
expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined,
assignments: r.assignments.length > 0 ? r.assignments : undefined,
isValid: isValidFormat && !isDuplicate,
isDuplicate,
error: !isValidFormat
@@ -298,6 +367,7 @@ export default function MemberInvitePage() {
name: u.name,
role: u.role,
expertiseTags: u.expertiseTags,
assignments: u.assignments,
})),
})
setSendProgress(100)
@@ -362,6 +432,35 @@ export default function MemberInvitePage() {
{inputMethod === 'manual' ? (
<div className="space-y-4">
{/* Round selector for pre-assignments */}
<div className="rounded-lg border border-dashed p-4 bg-muted/30">
<div className="flex items-center gap-3">
<FolderKanban className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<Label className="text-sm font-medium">Pre-assign Projects (Optional)</Label>
<p className="text-xs text-muted-foreground">
Select a round to assign projects to jury members before they onboard
</p>
</div>
<Select
value={selectedRoundId || 'none'}
onValueChange={(v) => setSelectedRoundId(v === 'none' ? '' : v)}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select round" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No pre-assignment</SelectItem>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Member cards */}
{rows.map((row, index) => (
<div
@@ -500,6 +599,81 @@ export default function MemberInvitePage() {
</div>
)}
</div>
{/* Per-member project pre-assignment (only for jury members) */}
{row.role === 'JURY_MEMBER' && selectedRoundId && (
<Collapsible className="space-y-2">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-between text-muted-foreground hover:text-foreground"
>
<span className="flex items-center gap-2">
<FolderKanban className="h-4 w-4" />
Pre-assign Projects
{row.assignments.length > 0 && (
<Badge variant="secondary" className="ml-1">
{row.assignments.length}
</Badge>
)}
</span>
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2">
{projectsLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">Loading projects...</span>
</div>
) : projects.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No projects in this round
</p>
) : (
<div className="max-h-48 overflow-y-auto space-y-1 border rounded-lg p-2">
{projects.map((project) => {
const isAssigned = row.assignments.some(
(a) => a.projectId === project.id
)
return (
<label
key={project.id}
className={cn(
'flex items-center gap-2 p-2 rounded-md cursor-pointer hover:bg-muted',
isAssigned && 'bg-primary/5'
)}
>
<Checkbox
checked={isAssigned}
onCheckedChange={() =>
toggleProjectAssignment(row.id, project.id)
}
/>
<span className="text-sm truncate flex-1">
{project.title}
</span>
</label>
)
})}
</div>
)}
{row.assignments.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => clearRowAssignments(row.id)}
className="text-xs text-muted-foreground"
>
Clear all assignments
</Button>
)}
</CollapsibleContent>
</Collapsible>
)}
</div>
))}
@@ -722,6 +896,9 @@ export default function MemberInvitePage() {
{result?.skipped
? ` ${result.skipped} skipped (already exist).`
: ''}
{result?.assignmentsCreated && result.assignmentsCreated > 0
? ` ${result.assignmentsCreated} project assignment${result.assignmentsCreated !== 1 ? 's' : ''} pre-assigned.`
: ''}
</p>
<div className="mt-6 flex gap-3">
<Button variant="outline" asChild>

View File

@@ -1,686 +0,0 @@
'use client'
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
Loader2,
Save,
Plus,
GripVertical,
Trash2,
Settings,
Eye,
Mail,
Link2,
} from 'lucide-react'
import { toast } from 'sonner'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { cn } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
}
// Sortable step item component
function SortableStep({
step,
isSelected,
onSelect,
onDelete,
}: {
step: { id: string; name: string; title: string; fields: unknown[] }
isSelected: boolean
onSelect: () => void
onDelete: () => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: step.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-colors',
isSelected ? 'border-primary bg-primary/5' : 'border-transparent hover:bg-muted',
isDragging && 'opacity-50'
)}
onClick={onSelect}
>
<button
{...attributes}
{...listeners}
className="cursor-grab text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{step.title}</p>
<p className="text-xs text-muted-foreground">
{(step.fields as unknown[]).length} fields
</p>
</div>
<button
className="p-1 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)
}
function OnboardingFormEditor({ formId }: { formId: string }) {
const router = useRouter()
const utils = trpc.useUtils()
// Fetch form data with steps
const { data: form, isLoading } = trpc.applicationForm.getForBuilder.useQuery(
{ id: formId },
{ refetchOnWindowFocus: false }
)
// Local state for editing
const [selectedStepId, setSelectedStepId] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
// Mutations
const updateForm = trpc.applicationForm.update.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Form updated')
},
onError: (error) => {
toast.error(error.message)
},
})
const createStep = trpc.applicationForm.createStep.useMutation({
onSuccess: (data) => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
setSelectedStepId(data.id)
toast.success('Step created')
},
})
const updateStep = trpc.applicationForm.updateStep.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Step updated')
},
})
const deleteStep = trpc.applicationForm.deleteStep.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
setSelectedStepId(null)
toast.success('Step deleted')
},
})
const reorderSteps = trpc.applicationForm.reorderSteps.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
},
})
const updateEmailSettings = trpc.applicationForm.updateEmailSettings.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Email settings updated')
},
})
const linkToRound = trpc.applicationForm.linkToRound.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Round linked')
},
})
// Fetch available rounds
const { data: availableRounds } = trpc.applicationForm.getAvailableRounds.useQuery({
programId: form?.programId || undefined,
})
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleStepDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id || !form) return
const oldIndex = form.steps.findIndex((s) => s.id === active.id)
const newIndex = form.steps.findIndex((s) => s.id === over.id)
const newOrder = arrayMove(form.steps, oldIndex, newIndex)
reorderSteps.mutate({
formId,
stepIds: newOrder.map((s) => s.id),
})
}
const handleAddStep = () => {
const stepNumber = (form?.steps.length || 0) + 1
createStep.mutate({
formId,
step: {
name: `step_${stepNumber}`,
title: `Step ${stepNumber}`,
},
})
}
const selectedStep = form?.steps.find((s) => s.id === selectedStepId)
if (isLoading) {
return <FormEditorSkeleton />
}
if (!form) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">Form not found</p>
<Link href="/admin/onboarding">
<Button variant="outline" className="mt-4">
Back to Onboarding
</Button>
</Link>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/onboarding">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{form.name}</h1>
<div className="flex items-center gap-2 mt-1">
<Badge variant={form.status === 'PUBLISHED' ? 'default' : 'secondary'}>
{form.status}
</Badge>
{form.round && (
<Badge variant="outline">
<Link2 className="mr-1 h-3 w-3" />
{form.round.name}
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
{form.publicSlug && form.status === 'PUBLISHED' && (
<a href={`/apply/${form.publicSlug}`} target="_blank" rel="noopener noreferrer">
<Button variant="outline">
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
</a>
)}
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="steps" className="space-y-6">
<TabsList>
<TabsTrigger value="steps">Steps & Fields</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="emails">Emails</TabsTrigger>
</TabsList>
{/* Steps Tab */}
<TabsContent value="steps" className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Steps List */}
<Card className="lg:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-base">Steps</CardTitle>
<CardDescription>
Drag to reorder wizard steps
</CardDescription>
</CardHeader>
<CardContent>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleStepDragEnd}
>
<SortableContext
items={form.steps.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{form.steps.map((step) => (
<SortableStep
key={step.id}
step={step}
isSelected={selectedStepId === step.id}
onSelect={() => setSelectedStepId(step.id)}
onDelete={() => {
if (confirm('Delete this step? Fields will be unassigned.')) {
deleteStep.mutate({ id: step.id })
}
}}
/>
))}
</div>
</SortableContext>
</DndContext>
<Button
variant="outline"
size="sm"
className="w-full mt-4"
onClick={handleAddStep}
disabled={createStep.isPending}
>
{createStep.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Add Step
</Button>
</CardContent>
</Card>
{/* Step Editor */}
<Card className="lg:col-span-2">
<CardHeader className="pb-3">
<CardTitle className="text-base">
{selectedStep ? `Edit: ${selectedStep.title}` : 'Select a Step'}
</CardTitle>
</CardHeader>
<CardContent>
{selectedStep ? (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Step Title</Label>
<Input
value={selectedStep.title}
onChange={(e) => {
updateStep.mutate({
id: selectedStep.id,
step: { title: e.target.value },
})
}}
/>
</div>
<div className="space-y-2">
<Label>Internal Name</Label>
<Input
value={selectedStep.name}
onChange={(e) => {
updateStep.mutate({
id: selectedStep.id,
step: { name: e.target.value },
})
}}
/>
</div>
</div>
<div className="space-y-2">
<Label>Description (optional)</Label>
<Textarea
value={selectedStep.description || ''}
onChange={(e) => {
updateStep.mutate({
id: selectedStep.id,
step: { description: e.target.value },
})
}}
rows={3}
/>
</div>
<div className="pt-4 border-t">
<h4 className="font-medium mb-3">Fields in this step</h4>
{selectedStep.fields.length === 0 ? (
<p className="text-sm text-muted-foreground">
No fields yet. Use the existing form editor to add fields.
</p>
) : (
<div className="space-y-2">
{selectedStep.fields.map((field) => (
<div
key={field.id}
className="flex items-center gap-3 p-3 rounded-lg border bg-muted/50"
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium text-sm">{field.label}</p>
<p className="text-xs text-muted-foreground">
{field.fieldType} {field.required && '(required)'}
</p>
</div>
</div>
))}
</div>
)}
<Link href={`/admin/forms/${formId}`}>
<Button variant="outline" size="sm" className="mt-4">
Edit Fields in Form Editor
</Button>
</Link>
</div>
</div>
) : (
<p className="text-muted-foreground text-center py-8">
Select a step from the list to edit it
</p>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Settings Tab */}
<TabsContent value="settings" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">General Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Form Name</Label>
<Input
value={form.name}
onChange={(e) => {
updateForm.mutate({ id: formId, name: e.target.value })
}}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={form.description || ''}
onChange={(e) => {
updateForm.mutate({ id: formId, description: e.target.value || null })
}}
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Public URL Slug</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">/apply/</span>
<Input
value={form.publicSlug || ''}
onChange={(e) => {
updateForm.mutate({ id: formId, publicSlug: e.target.value || null })
}}
placeholder="your-form-slug"
/>
</div>
</div>
<div className="flex items-center justify-between pt-4">
<div>
<Label>Public Access</Label>
<p className="text-sm text-muted-foreground">
Allow public submissions to this form
</p>
</div>
<Switch
checked={form.isPublic}
onCheckedChange={(checked) => {
updateForm.mutate({ id: formId, isPublic: checked })
}}
/>
</div>
<div className="space-y-2 pt-4">
<Label>Status</Label>
<Select
value={form.status}
onValueChange={(value) => {
updateForm.mutate({ id: formId, status: value as 'DRAFT' | 'PUBLISHED' | 'CLOSED' })
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="PUBLISHED">Published</SelectItem>
<SelectItem value="CLOSED">Closed</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Round Linking</CardTitle>
<CardDescription>
Link this form to a round to create projects on submission
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Linked Round</Label>
<Select
value={form.roundId || 'none'}
onValueChange={(value) => {
linkToRound.mutate({
formId,
roundId: value === 'none' ? null : value,
})
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No round linked</SelectItem>
{form.round && (
<SelectItem value={form.round.id}>
{form.round.name} (current)
</SelectItem>
)}
{availableRounds?.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.program?.name} {round.program?.year} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Emails Tab */}
<TabsContent value="emails" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Email Notifications</CardTitle>
<CardDescription>
Configure emails sent when applications are submitted
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Label>Confirmation Email</Label>
<p className="text-sm text-muted-foreground">
Send a confirmation email to the applicant
</p>
</div>
<Switch
checked={form.sendConfirmationEmail}
onCheckedChange={(checked) => {
updateEmailSettings.mutate({
formId,
sendConfirmationEmail: checked,
})
}}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Team Invite Emails</Label>
<p className="text-sm text-muted-foreground">
Send invite emails to team members
</p>
</div>
<Switch
checked={form.sendTeamInviteEmails}
onCheckedChange={(checked) => {
updateEmailSettings.mutate({
formId,
sendTeamInviteEmails: checked,
})
}}
/>
</div>
{form.sendConfirmationEmail && (
<>
<div className="space-y-2 pt-4 border-t">
<Label>Custom Email Subject (optional)</Label>
<Input
value={form.confirmationEmailSubject || ''}
onChange={(e) => {
updateEmailSettings.mutate({
formId,
confirmationEmailSubject: e.target.value || null,
})
}}
placeholder="Application Received - {projectName}"
/>
</div>
<div className="space-y-2">
<Label>Custom Email Message (optional)</Label>
<Textarea
value={form.confirmationEmailBody || ''}
onChange={(e) => {
updateEmailSettings.mutate({
formId,
confirmationEmailBody: e.target.value || null,
})
}}
placeholder="Add a custom message to include in the confirmation email..."
rows={4}
/>
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
function FormEditorSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-24" />
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-5 w-32" />
</div>
</div>
<Skeleton className="h-10 w-64" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Skeleton className="h-96" />
<Skeleton className="h-96 lg:col-span-2" />
</div>
</div>
)
}
export default function OnboardingFormPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<FormEditorSkeleton />}>
<OnboardingFormEditor formId={id} />
</Suspense>
)
}

View File

@@ -1,171 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ArrowLeft, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export default function NewOnboardingFormPage() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
// Fetch programs for selection
const { data: programs } = trpc.program.list.useQuery({})
// Fetch available rounds for the selected program
const { data: availableRounds } = trpc.applicationForm.getAvailableRounds.useQuery(
{ programId: selectedProgramId || undefined },
{ enabled: true }
)
const createForm = trpc.applicationForm.create.useMutation({
onSuccess: (data) => {
toast.success('Onboarding form created successfully')
router.push(`/admin/onboarding/${data.id}`)
},
onError: (error) => {
toast.error(error.message || 'Failed to create form')
setIsSubmitting(false)
},
})
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsSubmitting(true)
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const description = formData.get('description') as string
const publicSlug = formData.get('publicSlug') as string
createForm.mutate({
programId: selectedProgramId || null,
name,
description: description || undefined,
publicSlug: publicSlug || undefined,
})
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/onboarding">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Create Onboarding Form</h1>
<p className="text-muted-foreground">
Set up a new application wizard for project submissions
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Form Details</CardTitle>
<CardDescription>
Configure the basic settings for your onboarding form
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Form Name *</Label>
<Input
id="name"
name="name"
placeholder="e.g., MOPC 2026 Applications"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="program">Edition / Program</Label>
<Select
value={selectedProgramId}
onValueChange={setSelectedProgramId}
>
<SelectTrigger>
<SelectValue placeholder="Select a program (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">No program</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Link to a specific edition to enable project creation
</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
placeholder="Describe the purpose of this application form..."
rows={3}
maxLength={2000}
/>
</div>
<div className="space-y-2">
<Label htmlFor="publicSlug">Public URL Slug</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">/apply/</span>
<Input
id="publicSlug"
name="publicSlug"
placeholder="e.g., mopc-2026"
className="flex-1"
/>
</div>
<p className="text-sm text-muted-foreground">
Leave empty to generate automatically. Only lowercase letters, numbers, and hyphens.
</p>
</div>
<div className="flex justify-end gap-2 pt-4">
<Link href="/admin/onboarding">
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Form
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,153 +0,0 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Plus,
Pencil,
FileText,
ExternalLink,
Inbox,
Link2,
} from 'lucide-react'
const statusColors = {
DRAFT: 'bg-gray-100 text-gray-800',
PUBLISHED: 'bg-green-100 text-green-800',
CLOSED: 'bg-red-100 text-red-800',
}
async function OnboardingFormsList() {
const caller = await api()
const { data: forms } = await caller.applicationForm.list({
perPage: 50,
})
if (forms.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No onboarding forms yet</h3>
<p className="text-muted-foreground mb-4">
Create your first application wizard to accept project submissions
</p>
<Link href="/admin/onboarding/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Onboarding Form
</Button>
</Link>
</CardContent>
</Card>
)
}
return (
<div className="grid gap-4">
{forms.map((form) => (
<Card key={form.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<FileText className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{form.name}</h3>
<Badge className={statusColors[form.status as keyof typeof statusColors]}>
{form.status}
</Badge>
{form.program && (
<Badge variant="outline">{form.program.name} {form.program.year}</Badge>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
<span>{form._count.fields} fields</span>
<span>-</span>
<span>{form._count.submissions} submissions</span>
{form.publicSlug && (
<>
<span>-</span>
<span className="text-primary">/apply/{form.publicSlug}</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
{form.publicSlug && form.status === 'PUBLISHED' && (
<a
href={`/apply/${form.publicSlug}`}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="icon" title="View Public Form">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<Link href={`/admin/forms/${form.id}/submissions`}>
<Button variant="ghost" size="icon" title="View Submissions">
<Inbox className="h-4 w-4" />
</Button>
</Link>
<Link href={`/admin/onboarding/${form.id}`}>
<Button variant="ghost" size="icon" title="Edit Form">
<Pencil className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default function OnboardingPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Onboarding</h1>
<p className="text-muted-foreground">
Configure application wizards for project submissions
</p>
</div>
<Link href="/admin/onboarding/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Form
</Button>
</Link>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<OnboardingFormsList />
</Suspense>
</div>
)
}

View File

@@ -112,11 +112,11 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
where: { programId: editionId },
}),
prisma.project.count({
where: { programId: editionId },
where: { round: { programId: editionId } },
}),
prisma.project.count({
where: {
programId: editionId,
round: { programId: editionId },
createdAt: { gte: sevenDaysAgo },
},
}),
@@ -149,7 +149,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
include: {
_count: {
select: {
roundProjects: true,
projects: true,
assignments: true,
},
},
@@ -161,7 +161,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
},
}),
prisma.project.findMany({
where: { programId: editionId },
where: { round: { programId: editionId } },
orderBy: { createdAt: 'desc' },
take: 8,
select: {
@@ -174,20 +174,18 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
logoKey: true,
createdAt: true,
submittedAt: true,
roundProjects: {
select: { status: true, round: { select: { name: true } } },
take: 1,
},
status: true,
round: { select: { name: true } },
},
}),
prisma.project.groupBy({
by: ['competitionCategory'],
where: { programId: editionId },
where: { round: { programId: editionId } },
_count: true,
}),
prisma.project.groupBy({
by: ['oceanIssue'],
where: { programId: editionId },
where: { round: { programId: editionId } },
_count: true,
}),
])
@@ -394,7 +392,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{round._count.roundProjects} projects &middot; {round._count.assignments} assignments
{round._count.projects} projects &middot; {round._count.assignments} assignments
{round.totalEvals > 0 && (
<> &middot; {round.evalPercent}% evaluated</>
)}
@@ -461,10 +459,10 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
{truncate(project.title, 45)}
</p>
<Badge
variant={statusColors[project.roundProjects[0]?.status ?? 'SUBMITTED'] || 'secondary'}
variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}
className="shrink-0 text-[10px] px-1.5 py-0"
>
{(project.roundProjects[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">

View File

@@ -130,7 +130,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
{round.status}
</Badge>
</TableCell>
<TableCell>{round._count.roundProjects}</TableCell>
<TableCell>{round._count.projects}</TableCell>
<TableCell>{round._count.assignments}</TableCell>
<TableCell>{formatDateOnly(round.createdAt)}</TableCell>
</TableRow>

View File

@@ -121,7 +121,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
// Fetch existing tags for suggestions
const { data: existingTags } = trpc.project.getTags.useQuery({
roundId: project?.roundProjects?.[0]?.round?.id,
roundId: project?.roundId ?? undefined,
})
// Mutations
@@ -167,7 +167,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
title: project.title,
teamName: project.teamName || '',
description: project.description || '',
status: (project.roundProjects?.[0]?.status ?? 'SUBMITTED') as UpdateProjectForm['status'],
status: (project.status ?? 'SUBMITTED') as UpdateProjectForm['status'],
tags: project.tags || [],
})
}
@@ -202,7 +202,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
teamName: data.teamName || null,
description: data.description || null,
status: data.status,
roundId: project?.roundProjects?.[0]?.round?.id,
roundId: project?.roundId ?? undefined,
tags: data.tags,
})
}

View File

@@ -160,7 +160,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<p className="text-sm text-muted-foreground">{project.mentorAssignment!.mentor.email}</p>
{project.mentorAssignment!.mentor.expertiseTags && project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{project.mentorAssignment!.mentor.expertiseTags.slice(0, 3).map((tag) => (
{project.mentorAssignment!.mentor.expertiseTags.slice(0, 3).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>

View File

@@ -140,18 +140,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
/>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
{project.roundProjects?.length > 0 ? (
project.roundProjects.map((rp, i) => (
<span key={rp.round.id} className="flex items-center gap-1">
{i > 0 && <span className="text-muted-foreground/50">/</span>}
<Link
href={`/admin/rounds/${rp.round.id}`}
className="hover:underline"
>
{rp.round.name}
</Link>
</span>
))
{project.roundId ? (
<Link
href={`/admin/rounds/${project.roundId}`}
className="hover:underline"
>
{project.round?.name ?? 'Round'}
</Link>
) : (
<span>No round</span>
)}
@@ -160,8 +155,8 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<h1 className="text-2xl font-semibold tracking-tight">
{project.title}
</h1>
<Badge variant={statusColors[project.roundProjects?.[0]?.status ?? 'SUBMITTED'] || 'secondary'}>
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
<Badge variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}>
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</div>
{project.teamName && (
@@ -513,7 +508,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${project.roundProjects?.[0]?.round?.id}/assignments`}>
<Link href={`/admin/rounds/${project.roundId}/assignments`}>
Manage
</Link>
</Button>

View File

@@ -121,7 +121,6 @@ function NewProjectPageContent() {
})
createProject.mutate({
programId: selectedRound!.programId,
roundId: selectedRoundId,
title: title.trim(),
teamName: teamName.trim() || undefined,

View File

@@ -359,7 +359,7 @@ export default function ProjectsPage() {
</TableHeader>
<TableBody>
{data.projects.map((project) => {
const isEliminated = project.roundProjects?.[0]?.status === 'REJECTED'
const isEliminated = project.status === 'REJECTED'
return (
<TableRow
key={project.id}
@@ -388,15 +388,15 @@ export default function ProjectsPage() {
<TableCell>
<div>
<div className="flex items-center gap-2">
<p>{project.roundProjects?.[0]?.round?.name ?? '-'}</p>
{project.roundProjects?.[0]?.status === 'REJECTED' && (
<p>{project.round?.name ?? '-'}</p>
{project.status === 'REJECTED' && (
<Badge variant="destructive" className="text-xs">
Eliminated
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{project.program?.name}
{project.round?.program?.name}
</p>
</div>
</TableCell>
@@ -409,9 +409,9 @@ export default function ProjectsPage() {
</TableCell>
<TableCell>
<Badge
variant={statusColors[project.roundProjects?.[0]?.status ?? 'SUBMITTED'] || 'secondary'}
variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}
>
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="relative z-10 text-right">
@@ -478,11 +478,11 @@ export default function ProjectsPage() {
</CardTitle>
<Badge
variant={
statusColors[project.roundProjects?.[0]?.status ?? 'SUBMITTED'] || 'secondary'
statusColors[project.status ?? 'SUBMITTED'] || 'secondary'
}
className="shrink-0"
>
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</div>
<CardDescription>{project.teamName}</CardDescription>
@@ -493,8 +493,8 @@ export default function ProjectsPage() {
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<div className="flex items-center gap-2">
<span>{project.roundProjects?.[0]?.round?.name ?? '-'}</span>
{project.roundProjects?.[0]?.status === 'REJECTED' && (
<span>{project.round?.name ?? '-'}</span>
{project.status === 'REJECTED' && (
<Badge variant="destructive" className="text-xs">
Eliminated
</Badge>

View File

@@ -72,7 +72,7 @@ export interface ProjectFilters {
}
interface FilterOptions {
rounds: Array<{ id: string; name: string; sortOrder: number; program: { name: string; year: number } }>
rounds: Array<{ id: string; name: string; program: { name: string; year: number } }>
countries: string[]
categories: Array<{ value: string; count: number }>
issues: Array<{ value: string; count: number }>

View File

@@ -84,7 +84,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
const [formInitialized, setFormInitialized] = useState(false)
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
const [entryNotificationType, setEntryNotificationType] = useState<string>('')
// entryNotificationType removed from schema
// Fetch round data - disable refetch on focus to prevent overwriting user's edits
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery(
@@ -138,7 +138,6 @@ function EditRoundContent({ roundId }: { roundId: string }) {
// Set round type, settings, and notification type
setRoundType((round.roundType as typeof roundType) || 'EVALUATION')
setRoundSettings((round.settingsJson as Record<string, unknown>) || {})
setEntryNotificationType(round.entryNotificationType || '')
setFormInitialized(true)
}
}, [round, form, formInitialized])
@@ -166,7 +165,6 @@ function EditRoundContent({ roundId }: { roundId: string }) {
settingsJson: roundSettings,
votingStartAt: data.votingStartAt ?? null,
votingEndAt: data.votingEndAt ?? null,
entryNotificationType: entryNotificationType || null,
})
// Update evaluation form if criteria changed and no evaluations exist
@@ -353,38 +351,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
{/* Team Notification */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Bell className="h-5 w-5" />
Team Notification
</CardTitle>
<CardDescription>
Notification sent to project teams when they enter this round
</CardDescription>
</CardHeader>
<CardContent>
<Select
value={entryNotificationType || 'none'}
onValueChange={(val) => setEntryNotificationType(val === 'none' ? '' : val)}
>
<SelectTrigger>
<SelectValue placeholder="No automatic notification" />
</SelectTrigger>
<SelectContent>
{TEAM_NOTIFICATION_OPTIONS.map((option) => (
<SelectItem key={option.value || 'none'} value={option.value || 'none'}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground mt-2">
When projects advance to this round, the selected notification will be sent to the project team automatically.
</p>
</CardContent>
</Card>
{/* Team Notification - removed from schema, feature not implemented */}
{/* Evaluation Criteria */}
<Card>

View File

@@ -180,7 +180,7 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
if (storedOrder.length > 0) {
setProjectOrder(storedOrder)
} else {
setProjectOrder(sessionData.round.roundProjects.map((rp) => rp.project.id))
setProjectOrder(sessionData.round.projects.map((p) => p.id))
}
}
}, [sessionData])
@@ -253,7 +253,7 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
)
}
const projects = sessionData.round.roundProjects.map((rp) => rp.project)
const projects = sessionData.round.projects
const sortedProjects = projectOrder
.map((id) => projects.find((p) => p.id === id))
.filter((p): p is Project => !!p)

View File

@@ -367,7 +367,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round._count.roundProjects}</div>
<div className="text-2xl font-bold">{round._count.projects}</div>
<Button variant="link" size="sm" className="px-0" asChild>
<Link href={`/admin/projects?round=${round.id}`}>View projects</Link>
</Button>

View File

@@ -75,7 +75,7 @@ type RoundData = {
votingStartAt: string | null
votingEndAt: string | null
_count?: {
roundProjects: number
projects: number
assignments: number
}
}
@@ -238,7 +238,7 @@ function ProgramRounds({ program }: { program: any }) {
{round.name}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{round._count?.roundProjects || 0}
{round._count?.projects || 0}
</Badge>
</div>
{index < rounds.length - 1 && (
@@ -425,7 +425,7 @@ function SortableRoundRow({
{/* Projects */}
<div className="flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{round._count?.roundProjects || 0}</span>
<span className="font-medium">{round._count?.projects || 0}</span>
</div>
{/* Assignments */}
@@ -509,7 +509,7 @@ function SortableRoundRow({
<AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{round.name}&quot;? This will
remove {round._count?.roundProjects || 0} project assignments,{' '}
remove {round._count?.projects || 0} project assignments,{' '}
{round._count?.assignments || 0} reviewer assignments, and all evaluations
in this round. The projects themselves will remain in the program. This action cannot be undone.
</AlertDialogDescription>

View File

@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { PhoneInput } from '@/components/ui/phone-input'
import { CountrySelect } from '@/components/ui/country-select'
import {
Card,
CardContent,
@@ -23,6 +24,7 @@ import {
} from '@/components/ui/select'
import { toast } from 'sonner'
import { ExpertiseSelect } from '@/components/shared/expertise-select'
import { AvatarUpload } from '@/components/shared/avatar-upload'
import {
User,
Phone,
@@ -32,9 +34,11 @@ import {
Loader2,
ArrowRight,
ArrowLeft,
Camera,
Globe,
} from 'lucide-react'
type Step = 'name' | 'phone' | 'tags' | 'preferences' | 'complete'
type Step = 'name' | 'photo' | 'country' | 'phone' | 'tags' | 'preferences' | 'complete'
export default function OnboardingPage() {
const router = useRouter()
@@ -43,6 +47,7 @@ export default function OnboardingPage() {
// Form state
const [name, setName] = useState('')
const [country, setCountry] = useState('')
const [phoneNumber, setPhoneNumber] = useState('')
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
const [lockedTags, setLockedTags] = useState<string[]>([])
@@ -51,7 +56,8 @@ export default function OnboardingPage() {
>('EMAIL')
// Fetch current user data to get admin-preset tags
const { data: userData, isLoading: userLoading } = trpc.user.me.useQuery()
const { data: userData, isLoading: userLoading, refetch: refetchUser } = trpc.user.me.useQuery()
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
// Initialize form with user data
useEffect(() => {
@@ -60,6 +66,10 @@ export default function OnboardingPage() {
if (userData.name) {
setName(userData.name)
}
// Pre-fill country if available
if (userData.country) {
setCountry(userData.country)
}
// Pre-fill phone if available
if (userData.phoneNumber) {
setPhoneNumber(userData.phoneNumber)
@@ -86,10 +96,10 @@ export default function OnboardingPage() {
// Dynamic steps based on WhatsApp availability
const steps: Step[] = useMemo(() => {
if (whatsappEnabled) {
return ['name', 'phone', 'tags', 'preferences', 'complete']
return ['name', 'photo', 'country', 'phone', 'tags', 'preferences', 'complete']
}
// Skip phone step if WhatsApp is disabled
return ['name', 'tags', 'preferences', 'complete']
return ['name', 'photo', 'country', 'tags', 'preferences', 'complete']
}, [whatsappEnabled])
const currentIndex = steps.indexOf(step)
@@ -117,6 +127,7 @@ export default function OnboardingPage() {
try {
await completeOnboarding.mutateAsync({
name,
country: country || undefined,
phoneNumber: phoneNumber || undefined,
expertiseTags,
notificationPreference,
@@ -127,7 +138,9 @@ export default function OnboardingPage() {
// Redirect after a short delay based on user role
setTimeout(() => {
const role = userData?.role
if (role === 'MENTOR') {
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
router.push('/admin')
} else if (role === 'MENTOR') {
router.push('/mentor')
} else if (role === 'OBSERVER') {
router.push('/observer')
@@ -211,7 +224,85 @@ export default function OnboardingPage() {
</>
)}
{/* Step 2: Phone (only if WhatsApp enabled) */}
{/* Step 2: Profile Photo */}
{step === 'photo' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Camera className="h-5 w-5 text-primary" />
Profile Photo
</CardTitle>
<CardDescription>
Add a profile photo so others can recognize you. This step is optional.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-center">
<AvatarUpload
user={{
name: userData?.name,
email: userData?.email,
profileImageKey: userData?.profileImageKey,
}}
currentAvatarUrl={avatarUrl}
onUploadComplete={() => refetchUser()}
/>
</div>
<p className="text-sm text-muted-foreground text-center">
Click the avatar to upload a new photo. You can crop and adjust before saving.
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
{avatarUrl ? 'Continue' : 'Skip for now'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step 3: Home Country */}
{step === 'country' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5 text-primary" />
Home Country
</CardTitle>
<CardDescription>
Select your home country. This helps us match you with relevant projects.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="country">Country</Label>
<CountrySelect
value={country}
onChange={setCountry}
placeholder="Select your country"
/>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step 4: Phone (only if WhatsApp enabled) */}
{step === 'phone' && whatsappEnabled && (
<>
<CardHeader>
@@ -252,7 +343,7 @@ export default function OnboardingPage() {
</>
)}
{/* Step 3: Tags */}
{/* Step 5: Tags */}
{step === 'tags' && (
<>
<CardHeader>
@@ -286,7 +377,7 @@ export default function OnboardingPage() {
</>
)}
{/* Step 4: Preferences */}
{/* Step 6: Preferences */}
{step === 'preferences' && (
<>
<CardHeader>
@@ -338,6 +429,11 @@ export default function OnboardingPage() {
<p>
<span className="text-muted-foreground">Name:</span> {name}
</p>
{country && (
<p>
<span className="text-muted-foreground">Country:</span> {country}
</p>
)}
{whatsappEnabled && phoneNumber && (
<p>
<span className="text-muted-foreground">Phone:</span>{' '}
@@ -375,7 +471,7 @@ export default function OnboardingPage() {
</>
)}
{/* Step 5: Complete */}
{/* Step 7: Complete */}
{step === 'complete' && (
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="rounded-full bg-green-100 p-4 mb-4">

View File

@@ -149,22 +149,22 @@ export default function MentorDashboard() {
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
{project.program.year} Edition
{project.round?.program?.year} Edition
</span>
{project.roundProjects?.[0]?.round && (
{project.round && (
<>
<span></span>
<span>{project.roundProjects[0].round.name}</span>
<span>{project.round.name}</span>
</>
)}
</div>
<CardTitle className="flex items-center gap-2">
{project.title}
{project.roundProjects?.[0]?.status && (
{project.status && (
<Badge
variant={statusColors[project.roundProjects[0].status] || 'secondary'}
variant={statusColors[project.status] || 'secondary'}
>
{project.roundProjects[0].status.replace('_', ' ')}
{project.status.replace('_', ' ')}
</Badge>
)}
</CardTitle>

View File

@@ -109,12 +109,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
{project.program.year} Edition
{project.round?.program?.year} Edition
</span>
{project.roundProjects?.[0]?.round && (
{project.round && (
<>
<span></span>
<span>{project.roundProjects[0].round.name}</span>
<span>{project.round.name}</span>
</>
)}
</div>
@@ -122,9 +122,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<h1 className="text-2xl font-semibold tracking-tight">
{project.title}
</h1>
{project.roundProjects?.[0]?.status && (
<Badge variant={statusColors[project.roundProjects[0].status] || 'secondary'}>
{project.roundProjects[0].status.replace('_', ' ')}
{project.status && (
<Badge variant={statusColors[project.status] || 'secondary'}>
{project.status.replace('_', ' ')}
</Badge>
)}
</div>

View File

@@ -94,20 +94,20 @@ export default function MentorProjectsPage() {
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
{project.program.year} Edition
{project.round?.program?.year} Edition
</span>
{project.roundProjects?.[0]?.round && (
{project.round && (
<>
<span></span>
<span>{project.roundProjects[0].round.name}</span>
<span>{project.round.name}</span>
</>
)}
</div>
<CardTitle className="flex items-center gap-2">
{project.title}
{project.roundProjects?.[0]?.status && (
<Badge variant={statusColors[project.roundProjects[0].status] || 'secondary'}>
{project.roundProjects[0].status.replace('_', ' ')}
{project.status && (
<Badge variant={statusColors[project.status] || 'secondary'}>
{project.status.replace('_', ' ')}
</Badge>
)}
</CardTitle>

View File

@@ -48,7 +48,7 @@ async function ObserverDashboardContent() {
program: { select: { name: true, year: true } },
_count: {
select: {
roundProjects: true,
projects: true,
assignments: true,
},
},
@@ -176,7 +176,7 @@ async function ObserverDashboardContent() {
</p>
</div>
<div className="text-right text-sm">
<p>{round._count.roundProjects} projects</p>
<p>{round._count.projects} projects</p>
<p className="text-muted-foreground">
{round._count.assignments} assignments
</p>

View File

@@ -34,7 +34,7 @@ async function ReportsContent() {
},
_count: {
select: {
roundProjects: true,
projects: true,
assignments: true,
},
},
@@ -70,7 +70,7 @@ async function ReportsContent() {
})
// Calculate totals
const totalProjects = roundStats.reduce((acc, r) => acc + r._count.roundProjects, 0)
const totalProjects = roundStats.reduce((acc, r) => acc + r._count.projects, 0)
const totalAssignments = roundStats.reduce(
(acc, r) => acc + r.totalAssignments,
0
@@ -176,7 +176,7 @@ async function ReportsContent() {
</div>
</TableCell>
<TableCell>{round.program.name}</TableCell>
<TableCell>{round._count.roundProjects}</TableCell>
<TableCell>{round._count.projects}</TableCell>
<TableCell>
<div className="min-w-[120px] space-y-1">
<div className="flex justify-between text-sm">
@@ -237,7 +237,7 @@ async function ReportsContent() {
</p>
)}
<div className="flex items-center justify-between text-sm">
<span>{round._count.roundProjects} projects</span>
<span>{round._count.projects} projects</span>
<span className="text-muted-foreground">
{round.completedEvaluations}/{round.totalAssignments} evaluations
</span>

View File

@@ -1,423 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { motion, AnimatePresence } from 'motion/react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Waves,
AlertCircle,
Loader2,
CheckCircle,
ArrowLeft,
ArrowRight,
Clock,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
StepWelcome,
StepContact,
StepProject,
StepTeam,
StepAdditional,
StepReview,
} from '@/components/forms/apply-steps'
import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
import { cn } from '@/lib/utils'
// Form validation schema
const teamMemberSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
role: z.nativeEnum(TeamMemberRole).default('MEMBER'),
title: z.string().optional(),
})
const applicationSchema = z.object({
competitionCategory: z.nativeEnum(CompetitionCategory),
contactName: z.string().min(2, 'Full name is required'),
contactEmail: z.string().email('Invalid email address'),
contactPhone: z.string().min(5, 'Phone number is required'),
country: z.string().min(2, 'Country is required'),
city: z.string().optional(),
projectName: z.string().min(2, 'Project name is required').max(200),
teamName: z.string().optional(),
description: z.string().min(20, 'Description must be at least 20 characters'),
oceanIssue: z.nativeEnum(OceanIssue),
teamMembers: z.array(teamMemberSchema).optional(),
institution: z.string().optional(),
startupCreatedDate: z.string().optional(),
wantsMentorship: z.boolean().default(false),
referralSource: z.string().optional(),
gdprConsent: z.boolean().refine((val) => val === true, {
message: 'You must agree to the data processing terms',
}),
})
type ApplicationFormData = z.infer<typeof applicationSchema>
const STEPS = [
{ id: 'welcome', title: 'Category', fields: ['competitionCategory'] },
{ id: 'contact', title: 'Contact', fields: ['contactName', 'contactEmail', 'contactPhone', 'country'] },
{ id: 'project', title: 'Project', fields: ['projectName', 'description', 'oceanIssue'] },
{ id: 'team', title: 'Team', fields: [] },
{ id: 'additional', title: 'Details', fields: [] },
{ id: 'review', title: 'Review', fields: ['gdprConsent'] },
]
export default function ApplyWizardPage() {
const params = useParams()
const router = useRouter()
const slug = params.slug as string
const [currentStep, setCurrentStep] = useState(0)
const [direction, setDirection] = useState(0)
const [submitted, setSubmitted] = useState(false)
const [submissionMessage, setSubmissionMessage] = useState('')
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
{ roundSlug: slug },
{ retry: false }
)
const submitMutation = trpc.application.submit.useMutation({
onSuccess: (result) => {
setSubmitted(true)
setSubmissionMessage(result.message)
},
onError: (error) => {
toast.error(error.message)
},
})
const form = useForm<ApplicationFormData>({
resolver: zodResolver(applicationSchema),
defaultValues: {
competitionCategory: undefined,
contactName: '',
contactEmail: '',
contactPhone: '',
country: '',
city: '',
projectName: '',
teamName: '',
description: '',
oceanIssue: undefined,
teamMembers: [],
institution: '',
startupCreatedDate: '',
wantsMentorship: false,
referralSource: '',
gdprConsent: false,
},
mode: 'onChange',
})
const { watch, trigger, handleSubmit } = form
const competitionCategory = watch('competitionCategory')
const isBusinessConcept = competitionCategory === 'BUSINESS_CONCEPT'
const isStartup = competitionCategory === 'STARTUP'
const validateCurrentStep = async () => {
const currentFields = STEPS[currentStep].fields as (keyof ApplicationFormData)[]
if (currentFields.length === 0) return true
return await trigger(currentFields)
}
const nextStep = async () => {
const isValid = await validateCurrentStep()
if (isValid && currentStep < STEPS.length - 1) {
setDirection(1)
setCurrentStep((prev) => prev + 1)
}
}
const prevStep = () => {
if (currentStep > 0) {
setDirection(-1)
setCurrentStep((prev) => prev - 1)
}
}
const onSubmit = async (data: ApplicationFormData) => {
if (!config) return
await submitMutation.mutateAsync({
roundId: config.round.id,
data,
})
}
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && currentStep < STEPS.length - 1) {
e.preventDefault()
nextStep()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentStep])
// Loading state
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-2xl space-y-6">
<div className="flex items-center justify-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-lg text-muted-foreground">Loading application...</span>
</div>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center">
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
<p className="text-muted-foreground mb-6">{error.message}</p>
<Button variant="outline" onClick={() => router.push('/')}>
Return Home
</Button>
</div>
</div>
)
}
// Applications closed state
if (config && !config.round.isOpen) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center">
<Clock className="mx-auto h-16 w-16 text-muted-foreground mb-4" />
<h1 className="text-2xl font-bold mb-2">Applications Closed</h1>
<p className="text-muted-foreground mb-6">
The application period for {config.program.name} {config.program.year} has ended.
{config.round.submissionEndDate && (
<span className="block mt-2">
Submissions closed on{' '}
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</span>
)}
</p>
<Button variant="outline" onClick={() => router.push('/')}>
Return Home
</Button>
</div>
</div>
)
}
// Success state
if (submitted) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="w-full max-w-md text-center"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring' }}
>
<CheckCircle className="mx-auto h-20 w-20 text-green-500 mb-6" />
</motion.div>
<h1 className="text-3xl font-bold mb-4">Application Submitted!</h1>
<p className="text-muted-foreground mb-8">{submissionMessage}</p>
<Button onClick={() => router.push('/')}>
Return Home
</Button>
</motion.div>
</div>
)
}
if (!config) return null
const progress = ((currentStep + 1) / STEPS.length) * 100
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 50 : -50,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 50 : -50,
opacity: 0,
}),
}
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30">
{/* Header */}
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto max-w-4xl px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Waves className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="font-semibold">{config.program.name}</h1>
<p className="text-xs text-muted-foreground">{config.program.year} Application</p>
</div>
</div>
<div className="text-right">
<span className="text-sm text-muted-foreground">
Step {currentStep + 1} of {STEPS.length}
</span>
</div>
</div>
{/* Progress bar */}
<div className="mt-4 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full bg-gradient-to-r from-primary to-primary/70"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
{/* Step indicators */}
<div className="mt-3 flex justify-between">
{STEPS.map((step, index) => (
<button
key={step.id}
type="button"
onClick={() => {
if (index < currentStep) {
setDirection(index < currentStep ? -1 : 1)
setCurrentStep(index)
}
}}
disabled={index > currentStep}
className={cn(
'hidden text-xs font-medium transition-colors sm:block',
index === currentStep && 'text-primary',
index < currentStep && 'text-muted-foreground hover:text-foreground cursor-pointer',
index > currentStep && 'text-muted-foreground/50'
)}
>
{step.title}
</button>
))}
</div>
</div>
</header>
{/* Main content */}
<main className="mx-auto max-w-4xl px-4 py-8">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="relative min-h-[500px]">
<AnimatePresence initial={false} custom={direction} mode="wait">
<motion.div
key={currentStep}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: 'spring', stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}}
className="w-full"
>
{currentStep === 0 && (
<StepWelcome
programName={config.program.name}
programYear={config.program.year}
value={competitionCategory}
onChange={(value) => form.setValue('competitionCategory', value)}
/>
)}
{currentStep === 1 && <StepContact form={form} />}
{currentStep === 2 && <StepProject form={form} />}
{currentStep === 3 && <StepTeam form={form} />}
{currentStep === 4 && (
<StepAdditional
form={form}
isBusinessConcept={isBusinessConcept}
isStartup={isStartup}
/>
)}
{currentStep === 5 && (
<StepReview form={form} programName={config.program.name} />
)}
</motion.div>
</AnimatePresence>
</div>
{/* Navigation buttons */}
<div className="mt-8 flex items-center justify-between border-t pt-6">
<Button
type="button"
variant="ghost"
onClick={prevStep}
disabled={currentStep === 0 || submitMutation.isPending}
className={cn(currentStep === 0 && 'invisible')}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={nextStep}>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : (
<Button type="submit" disabled={submitMutation.isPending}>
{submitMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Submit Application
</>
)}
</Button>
)}
</div>
</form>
</main>
{/* Footer with deadline info */}
{config.round.submissionEndDate && (
<footer className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3">
<div className="mx-auto max-w-4xl px-4 text-center text-sm text-muted-foreground">
<Clock className="inline-block mr-1 h-4 w-4" />
Applications due by{' '}
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</div>
</footer>
)}
</div>
)
}

View File

@@ -1,430 +1,423 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { motion, AnimatePresence } from 'motion/react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react'
import {
Waves,
AlertCircle,
Loader2,
CheckCircle,
ArrowLeft,
ArrowRight,
Clock,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
StepWelcome,
StepContact,
StepProject,
StepTeam,
StepAdditional,
StepReview,
} from '@/components/forms/apply-steps'
import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
import { cn } from '@/lib/utils'
type FormField = {
id: string
fieldType: string
name: string
label: string
description?: string | null
placeholder?: string | null
required: boolean
minLength?: number | null
maxLength?: number | null
minValue?: number | null
maxValue?: number | null
optionsJson: Array<{ value: string; label: string }> | null
conditionJson: { fieldId: string; operator: string; value?: string } | null
width: string
}
// Form validation schema
const teamMemberSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
role: z.nativeEnum(TeamMemberRole).default('MEMBER'),
title: z.string().optional(),
})
export default function PublicFormPage() {
const applicationSchema = z.object({
competitionCategory: z.nativeEnum(CompetitionCategory),
contactName: z.string().min(2, 'Full name is required'),
contactEmail: z.string().email('Invalid email address'),
contactPhone: z.string().min(5, 'Phone number is required'),
country: z.string().min(2, 'Country is required'),
city: z.string().optional(),
projectName: z.string().min(2, 'Project name is required').max(200),
teamName: z.string().optional(),
description: z.string().min(20, 'Description must be at least 20 characters'),
oceanIssue: z.nativeEnum(OceanIssue),
teamMembers: z.array(teamMemberSchema).optional(),
institution: z.string().optional(),
startupCreatedDate: z.string().optional(),
wantsMentorship: z.boolean().default(false),
referralSource: z.string().optional(),
gdprConsent: z.boolean().refine((val) => val === true, {
message: 'You must agree to the data processing terms',
}),
})
type ApplicationFormData = z.infer<typeof applicationSchema>
const STEPS = [
{ id: 'welcome', title: 'Category', fields: ['competitionCategory'] },
{ id: 'contact', title: 'Contact', fields: ['contactName', 'contactEmail', 'contactPhone', 'country'] },
{ id: 'project', title: 'Project', fields: ['projectName', 'description', 'oceanIssue'] },
{ id: 'team', title: 'Team', fields: [] },
{ id: 'additional', title: 'Details', fields: [] },
{ id: 'review', title: 'Review', fields: ['gdprConsent'] },
]
export default function ApplyWizardPage() {
const params = useParams()
const router = useRouter()
const slug = params.slug as string
const [submitted, setSubmitted] = useState(false)
const [confirmationMessage, setConfirmationMessage] = useState<string | null>(null)
const { data: form, isLoading, error } = trpc.applicationForm.getBySlug.useQuery(
{ slug },
const [currentStep, setCurrentStep] = useState(0)
const [direction, setDirection] = useState(0)
const [submitted, setSubmitted] = useState(false)
const [submissionMessage, setSubmissionMessage] = useState('')
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
{ roundSlug: slug },
{ retry: false }
)
const submitMutation = trpc.applicationForm.submit.useMutation({
const submitMutation = trpc.application.submit.useMutation({
onSuccess: (result) => {
setSubmitted(true)
setConfirmationMessage(result.confirmationMessage || null)
setSubmissionMessage(result.message)
},
onError: (error) => {
toast.error(error.message)
},
})
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
setValue,
} = useForm()
const form = useForm<ApplicationFormData>({
resolver: zodResolver(applicationSchema),
defaultValues: {
competitionCategory: undefined,
contactName: '',
contactEmail: '',
contactPhone: '',
country: '',
city: '',
projectName: '',
teamName: '',
description: '',
oceanIssue: undefined,
teamMembers: [],
institution: '',
startupCreatedDate: '',
wantsMentorship: false,
referralSource: '',
gdprConsent: false,
},
mode: 'onChange',
})
const watchedValues = watch()
const { watch, trigger, handleSubmit } = form
const competitionCategory = watch('competitionCategory')
const onSubmit = async (data: Record<string, unknown>) => {
if (!form) return
const isBusinessConcept = competitionCategory === 'BUSINESS_CONCEPT'
const isStartup = competitionCategory === 'STARTUP'
// Extract email and name if present
const emailField = form.fields.find((f) => f.fieldType === 'EMAIL')
const email = emailField ? (data[emailField.name] as string) : undefined
const validateCurrentStep = async () => {
const currentFields = STEPS[currentStep].fields as (keyof ApplicationFormData)[]
if (currentFields.length === 0) return true
return await trigger(currentFields)
}
// Find a name field (common patterns)
const nameField = form.fields.find(
(f) => f.name.toLowerCase().includes('name') && f.fieldType === 'TEXT'
)
const name = nameField ? (data[nameField.name] as string) : undefined
const nextStep = async () => {
const isValid = await validateCurrentStep()
if (isValid && currentStep < STEPS.length - 1) {
setDirection(1)
setCurrentStep((prev) => prev + 1)
}
}
const prevStep = () => {
if (currentStep > 0) {
setDirection(-1)
setCurrentStep((prev) => prev - 1)
}
}
const onSubmit = async (data: ApplicationFormData) => {
if (!config) return
await submitMutation.mutateAsync({
formId: form.id,
roundId: config.round.id,
data,
email,
name,
})
}
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && currentStep < STEPS.length - 1) {
e.preventDefault()
nextStep()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentStep])
// Loading state
if (isLoading) {
return (
<div className="max-w-2xl mx-auto space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-full" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-10 w-full" />
</div>
))}
</CardContent>
</Card>
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-2xl space-y-6">
<div className="flex items-center justify-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-lg text-muted-foreground">Loading application...</span>
</div>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
<h2 className="text-xl font-semibold mb-2">Form Not Available</h2>
<p className="text-muted-foreground text-center">
{error.message}
</p>
</CardContent>
</Card>
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center">
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
<p className="text-muted-foreground mb-6">{error.message}</p>
<Button variant="outline" onClick={() => router.push('/')}>
Return Home
</Button>
</div>
</div>
)
}
// Applications closed state
if (config && !config.round.isOpen) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center">
<Clock className="mx-auto h-16 w-16 text-muted-foreground mb-4" />
<h1 className="text-2xl font-bold mb-2">Applications Closed</h1>
<p className="text-muted-foreground mb-6">
The application period for {config.program.name} {config.program.year} has ended.
{config.round.submissionEndDate && (
<span className="block mt-2">
Submissions closed on{' '}
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</span>
)}
</p>
<Button variant="outline" onClick={() => router.push('/')}>
Return Home
</Button>
</div>
</div>
)
}
// Success state
if (submitted) {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
<h2 className="text-xl font-semibold mb-2">Thank You!</h2>
<p className="text-muted-foreground text-center">
{confirmationMessage || 'Your submission has been received.'}
</p>
</CardContent>
</Card>
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="w-full max-w-md text-center"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring' }}
>
<CheckCircle className="mx-auto h-20 w-20 text-green-500 mb-6" />
</motion.div>
<h1 className="text-3xl font-bold mb-4">Application Submitted!</h1>
<p className="text-muted-foreground mb-8">{submissionMessage}</p>
<Button onClick={() => router.push('/')}>
Return Home
</Button>
</motion.div>
</div>
)
}
if (!form) return null
if (!config) return null
// Check if a field should be visible based on conditions
const isFieldVisible = (field: FormField): boolean => {
if (!field.conditionJson) return true
const progress = ((currentStep + 1) / STEPS.length) * 100
const condition = field.conditionJson
const dependentValue = watchedValues[form.fields.find((f) => f.id === condition.fieldId)?.name || '']
switch (condition.operator) {
case 'equals':
return dependentValue === condition.value
case 'not_equals':
return dependentValue !== condition.value
case 'not_empty':
return !!dependentValue && dependentValue !== ''
case 'contains':
return typeof dependentValue === 'string' && dependentValue.includes(condition.value || '')
default:
return true
}
}
const renderField = (field: FormField) => {
if (!isFieldVisible(field)) return null
const fieldError = errors[field.name]
const errorMessage = fieldError?.message as string | undefined
switch (field.fieldType) {
case 'SECTION':
return (
<div key={field.id} className="col-span-full pt-6 pb-2">
<h3 className="text-lg font-semibold">{field.label}</h3>
{field.description && (
<p className="text-sm text-muted-foreground">{field.description}</p>
)}
</div>
)
case 'INSTRUCTIONS':
return (
<div key={field.id} className="col-span-full">
<div className="bg-muted p-4 rounded-lg">
<p className="text-sm">{field.description || field.label}</p>
</div>
</div>
)
case 'TEXT':
case 'EMAIL':
case 'PHONE':
case 'URL':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<Input
id={field.name}
type={field.fieldType === 'EMAIL' ? 'email' : field.fieldType === 'URL' ? 'url' : 'text'}
placeholder={field.placeholder || undefined}
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
minLength: field.minLength ? { value: field.minLength, message: `Minimum ${field.minLength} characters` } : undefined,
maxLength: field.maxLength ? { value: field.maxLength, message: `Maximum ${field.maxLength} characters` } : undefined,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'NUMBER':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<Input
id={field.name}
type="number"
placeholder={field.placeholder || undefined}
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
valueAsNumber: true,
min: field.minValue ? { value: field.minValue, message: `Minimum value is ${field.minValue}` } : undefined,
max: field.maxValue ? { value: field.maxValue, message: `Maximum value is ${field.maxValue}` } : undefined,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'TEXTAREA':
return (
<div key={field.id} className="col-span-full">
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<Textarea
id={field.name}
placeholder={field.placeholder || undefined}
rows={4}
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
minLength: field.minLength ? { value: field.minLength, message: `Minimum ${field.minLength} characters` } : undefined,
maxLength: field.maxLength ? { value: field.maxLength, message: `Maximum ${field.maxLength} characters` } : undefined,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'DATE':
case 'DATETIME':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<Input
id={field.name}
type={field.fieldType === 'DATETIME' ? 'datetime-local' : 'date'}
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'SELECT':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<Select
onValueChange={(value) => setValue(field.name, value)}
>
<SelectTrigger>
<SelectValue placeholder={field.placeholder || 'Select an option'} />
</SelectTrigger>
<SelectContent>
{(field.optionsJson || []).map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="hidden"
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'RADIO':
return (
<div key={field.id} className="col-span-full">
<Label>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<RadioGroup
onValueChange={(value) => setValue(field.name, value)}
className="mt-2"
>
{(field.optionsJson || []).map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`${field.name}-${option.value}`} />
<Label htmlFor={`${field.name}-${option.value}`} className="font-normal">
{option.label}
</Label>
</div>
))}
</RadioGroup>
<input
type="hidden"
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'CHECKBOX':
return (
<div key={field.id} className="col-span-full">
<div className="flex items-center space-x-2">
<Checkbox
id={field.name}
onCheckedChange={(checked) => setValue(field.name, checked)}
/>
<Label htmlFor={field.name} className="font-normal">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
</div>
{field.description && (
<p className="text-xs text-muted-foreground ml-6">{field.description}</p>
)}
<input
type="hidden"
{...register(field.name, {
validate: field.required ? (value) => value === true || `${field.label} is required` : undefined,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
default:
return null
}
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 50 : -50,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 50 : -50,
opacity: 0,
}),
}
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardHeader>
<CardTitle>{form.name}</CardTitle>
{form.description && (
<CardDescription>{form.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
{form.fields.map((field) => renderField(field as FormField))}
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30">
{/* Header */}
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto max-w-4xl px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Waves className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="font-semibold">{config.program.name}</h1>
<p className="text-xs text-muted-foreground">{config.program.year} Application</p>
</div>
</div>
<div className="text-right">
<span className="text-sm text-muted-foreground">
Step {currentStep + 1} of {STEPS.length}
</span>
</div>
</div>
{/* Progress bar */}
<div className="mt-4 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full bg-gradient-to-r from-primary to-primary/70"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
{/* Step indicators */}
<div className="mt-3 flex justify-between">
{STEPS.map((step, index) => (
<button
key={step.id}
type="button"
onClick={() => {
if (index < currentStep) {
setDirection(index < currentStep ? -1 : 1)
setCurrentStep(index)
}
}}
disabled={index > currentStep}
className={cn(
'hidden text-xs font-medium transition-colors sm:block',
index === currentStep && 'text-primary',
index < currentStep && 'text-muted-foreground hover:text-foreground cursor-pointer',
index > currentStep && 'text-muted-foreground/50'
)}
>
{step.title}
</button>
))}
</div>
</div>
</header>
{/* Main content */}
<main className="mx-auto max-w-4xl px-4 py-8">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="relative min-h-[500px]">
<AnimatePresence initial={false} custom={direction} mode="wait">
<motion.div
key={currentStep}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: 'spring', stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}}
className="w-full"
>
{currentStep === 0 && (
<StepWelcome
programName={config.program.name}
programYear={config.program.year}
value={competitionCategory}
onChange={(value) => form.setValue('competitionCategory', value)}
/>
)}
{currentStep === 1 && <StepContact form={form} />}
{currentStep === 2 && <StepProject form={form} />}
{currentStep === 3 && <StepTeam form={form} />}
{currentStep === 4 && (
<StepAdditional
form={form}
isBusinessConcept={isBusinessConcept}
isStartup={isStartup}
/>
)}
{currentStep === 5 && (
<StepReview form={form} programName={config.program.name} />
)}
</motion.div>
</AnimatePresence>
</div>
{/* Navigation buttons */}
<div className="mt-8 flex items-center justify-between border-t pt-6">
<Button
type="submit"
className="w-full"
disabled={isSubmitting || submitMutation.isPending}
type="button"
variant="ghost"
onClick={prevStep}
disabled={currentStep === 0 || submitMutation.isPending}
className={cn(currentStep === 0 && 'invisible')}
>
{(isSubmitting || submitMutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Submit
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</form>
</CardContent>
</Card>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={nextStep}>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : (
<Button type="submit" disabled={submitMutation.isPending}>
{submitMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Submit Application
</>
)}
</Button>
)}
</div>
</form>
</main>
{/* Footer with deadline info */}
{config.round.submissionEndDate && (
<footer className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3">
<div className="mx-auto max-w-4xl px-4 text-center text-sm text-muted-foreground">
<Clock className="inline-block mr-1 h-4 w-4" />
Applications due by{' '}
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</div>
</footer>
)}
</div>
)
}

View File

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

View File

@@ -1,160 +0,0 @@
import { redirect } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { prisma } from '@/lib/prisma'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/shared/logo'
import { FileText, Calendar, ArrowRight, ExternalLink } from 'lucide-react'
export const dynamic = 'force-dynamic'
export default async function ApplyLandingPage() {
// Get all published, public application forms
const forms = await prisma.applicationForm.findMany({
where: {
status: 'PUBLISHED',
isPublic: true,
OR: [
{ opensAt: null },
{ opensAt: { lte: new Date() } },
],
AND: [
{
OR: [
{ closesAt: null },
{ closesAt: { gte: new Date() } },
],
},
],
},
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
description: true,
publicSlug: true,
opensAt: true,
closesAt: true,
steps: {
select: { id: true },
},
},
})
// If exactly one form is available, redirect to it
if (forms.length === 1 && forms[0].publicSlug) {
const form = forms[0]
const hasSteps = form.steps && form.steps.length > 0
const url = hasSteps
? `/apply/${form.publicSlug}/wizard`
: `/apply/${form.publicSlug}`
redirect(url as Route)
}
// If no forms are available, show a message
if (forms.length === 0) {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white dark:from-slate-950 dark:to-slate-900">
<div className="container max-w-2xl py-16">
<div className="text-center mb-12">
<Logo variant="long" />
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<FileText className="h-16 w-16 text-muted-foreground/30 mb-6" />
<h1 className="text-2xl font-semibold mb-3">Applications Not Open</h1>
<p className="text-muted-foreground text-center max-w-md">
There are currently no open applications. Please check back later
or visit our website for more information.
</p>
<Button asChild className="mt-8">
<a href="https://monaco-opc.com" target="_blank" rel="noopener noreferrer">
Visit Website
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</CardContent>
</Card>
</div>
</div>
)
}
// Multiple forms available - show selection
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white dark:from-slate-950 dark:to-slate-900">
<div className="container max-w-4xl py-16">
<div className="text-center mb-12">
<Logo variant="long" />
<h1 className="text-3xl font-bold mt-8 mb-3">Apply Now</h1>
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
Select an application form below to get started.
</p>
</div>
<div className="grid gap-6">
{forms.map((form) => {
const hasSteps = form.steps && form.steps.length > 0
const url = hasSteps
? `/apply/${form.publicSlug}/wizard`
: `/apply/${form.publicSlug}`
return (
<Card key={form.id} className="overflow-hidden hover:shadow-lg transition-shadow">
<Link href={url as Route} className="block">
<div className="flex items-stretch">
<div className="flex-1 p-6">
<CardHeader className="p-0 pb-2">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" />
{form.name}
</CardTitle>
{form.description && (
<CardDescription className="line-clamp-2">
{form.description}
</CardDescription>
)}
</CardHeader>
{(form.opensAt || form.closesAt) && (
<div className="flex items-center gap-4 mt-4 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
{form.closesAt && (
<span>
Closes: {new Date(form.closesAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</span>
)}
</div>
)}
</div>
<div className="flex items-center px-6 bg-muted/30 border-l">
<Button variant="ghost" size="icon" className="rounded-full">
<ArrowRight className="h-5 w-5" />
</Button>
</div>
</div>
</Link>
</Card>
)
})}
</div>
<div className="text-center mt-12">
<p className="text-sm text-muted-foreground">
Having trouble? Contact us at{' '}
<a href="mailto:support@monaco-opc.com" className="text-primary hover:underline">
support@monaco-opc.com
</a>
</p>
</div>
</div>
</div>
)
}

View File

@@ -132,7 +132,7 @@ export function SubmissionDetailClient() {
</Badge>
</div>
<p className="text-muted-foreground">
{project.roundProjects?.[0]?.round?.program?.year ? `${project.roundProjects[0].round.program.year} Edition` : ''}{project.roundProjects?.[0]?.round?.name ? ` - ${project.roundProjects[0].round.name}` : ''}
{project.round?.program?.year ? `${project.round.program.year} Edition` : ''}{project.round?.name ? ` - ${project.round.name}` : ''}
</p>
</div>
</div>

View File

@@ -132,10 +132,9 @@ export function MySubmissionClient() {
) : (
<div className="space-y-4">
{submissions.map((project) => {
const latestRoundProject = project.roundProjects?.[0]
const projectStatus = latestRoundProject?.status ?? 'SUBMITTED'
const roundName = latestRoundProject?.round?.name
const programYear = latestRoundProject?.round?.program?.year
const projectStatus = project.status ?? 'SUBMITTED'
const roundName = project.round?.name
const programYear = project.round?.program?.year
return (
<Card key={project.id}>