Initial commit: MOPC platform with Docker deployment setup

Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth.
Includes production Dockerfile (multi-stage, port 7600), docker-compose
with registry-based image pull, Gitea Actions CI workflow, nginx config
for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 13:41:32 +01:00
commit a606292aaa
290 changed files with 70691 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
export { StepWelcome } from './step-welcome'
export { StepContact } from './step-contact'
export { StepProject } from './step-project'
export { StepTeam } from './step-team'
export { StepAdditional } from './step-additional'
export { StepReview } from './step-review'

View File

@@ -0,0 +1,138 @@
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import { Calendar, GraduationCap, Heart } from 'lucide-react'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import type { ApplicationFormData } from '@/server/routers/application'
interface StepAdditionalProps {
form: UseFormReturn<ApplicationFormData>
isBusinessConcept: boolean
isStartup: boolean
}
export function StepAdditional({ form, isBusinessConcept, isStartup }: StepAdditionalProps) {
const { register, formState: { errors }, setValue, watch } = form
const wantsMentorship = watch('wantsMentorship')
return (
<WizardStepContent
title="A few more details"
description="Help us understand your background and needs."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-md space-y-8"
>
{/* Institution (for Business Concepts) */}
{isBusinessConcept && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-2"
>
<div className="flex items-center gap-2">
<GraduationCap className="h-5 w-5 text-muted-foreground" />
<Label htmlFor="institution">
University/School <span className="text-destructive">*</span>
</Label>
</div>
<Input
id="institution"
placeholder="MIT, Stanford, INSEAD..."
{...register('institution')}
className="h-12 text-base"
/>
{errors.institution && (
<p className="text-sm text-destructive">{errors.institution.message}</p>
)}
<p className="text-xs text-muted-foreground">
Enter the name of your university or educational institution.
</p>
</motion.div>
)}
{/* Startup Created Date (for Startups) */}
{isStartup && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-2"
>
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-muted-foreground" />
<Label htmlFor="startupCreatedDate">
When was your startup created? <span className="text-destructive">*</span>
</Label>
</div>
<Input
id="startupCreatedDate"
type="date"
{...register('startupCreatedDate')}
className="h-12 text-base"
/>
{errors.startupCreatedDate && (
<p className="text-sm text-destructive">{errors.startupCreatedDate.message}</p>
)}
<p className="text-xs text-muted-foreground">
Enter the date your startup was officially registered or founded.
</p>
</motion.div>
)}
{/* Mentorship */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="rounded-lg border bg-card p-6"
>
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Heart className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<Label htmlFor="wantsMentorship" className="text-base font-medium">
Would you like mentorship support?
</Label>
<Switch
id="wantsMentorship"
checked={wantsMentorship}
onCheckedChange={(checked) => setValue('wantsMentorship', checked)}
/>
</div>
<p className="mt-2 text-sm text-muted-foreground">
Our mentors are industry experts who can help guide your project.
This is optional but highly recommended.
</p>
</div>
</div>
</motion.div>
{/* Referral Source */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="space-y-2"
>
<Label htmlFor="referralSource">
How did you hear about us? <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id="referralSource"
placeholder="Friend, Social media, Event..."
{...register('referralSource')}
className="h-12 text-base"
/>
</motion.div>
</motion.div>
</WizardStepContent>
)
}

View File

@@ -0,0 +1,111 @@
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import { WizardStepContent } from '@/components/forms/form-wizard'
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 type { ApplicationFormData } from '@/server/routers/application'
interface StepContactProps {
form: UseFormReturn<ApplicationFormData>
}
export function StepContact({ form }: StepContactProps) {
const { register, formState: { errors }, setValue, watch } = form
const country = watch('country')
const phone = watch('contactPhone')
return (
<WizardStepContent
title="Tell us about yourself"
description="We'll use this information to contact you about your application."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-md space-y-6"
>
{/* Full Name */}
<div className="space-y-2">
<Label htmlFor="contactName">
Full Name <span className="text-destructive">*</span>
</Label>
<Input
id="contactName"
placeholder="John Smith"
{...register('contactName')}
className="h-12 text-base"
/>
{errors.contactName && (
<p className="text-sm text-destructive">{errors.contactName.message}</p>
)}
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="contactEmail">
Email Address <span className="text-destructive">*</span>
</Label>
<Input
id="contactEmail"
type="email"
placeholder="john@example.com"
{...register('contactEmail')}
className="h-12 text-base"
/>
{errors.contactEmail && (
<p className="text-sm text-destructive">{errors.contactEmail.message}</p>
)}
</div>
{/* Phone */}
<div className="space-y-2">
<Label htmlFor="contactPhone">
Phone Number <span className="text-destructive">*</span>
</Label>
<PhoneInput
value={phone}
onChange={(value) => setValue('contactPhone', value || '')}
defaultCountry="MC"
className="h-12"
/>
{errors.contactPhone && (
<p className="text-sm text-destructive">{errors.contactPhone.message}</p>
)}
</div>
{/* Country */}
<div className="space-y-2">
<Label>
Country <span className="text-destructive">*</span>
</Label>
<CountrySelect
value={country}
onChange={(value) => setValue('country', value)}
placeholder="Select your country"
className="h-12"
/>
{errors.country && (
<p className="text-sm text-destructive">{errors.country.message}</p>
)}
</div>
{/* City (optional) */}
<div className="space-y-2">
<Label htmlFor="city">
City <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id="city"
placeholder="Monaco"
{...register('city')}
className="h-12 text-base"
/>
</div>
</motion.div>
</WizardStepContent>
)
}

View File

@@ -0,0 +1,141 @@
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import { WizardStepContent } from '@/components/forms/form-wizard'
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 type { ApplicationFormData } from '@/server/routers/application'
import { OceanIssue } from '@prisma/client'
interface OceanIssueOption {
value: OceanIssue
label: string
}
const oceanIssueOptions: OceanIssueOption[] = [
{ value: 'POLLUTION_REDUCTION', label: 'Reduction of pollution (plastics, chemicals, noise, light,...)' },
{ value: 'CLIMATE_MITIGATION', label: 'Mitigation of climate change and sea-level rise' },
{ value: 'TECHNOLOGY_INNOVATION', label: 'Technology & innovations' },
{ value: 'SUSTAINABLE_SHIPPING', label: 'Sustainable shipping & yachting' },
{ value: 'BLUE_CARBON', label: 'Blue carbon' },
{ value: 'HABITAT_RESTORATION', label: 'Restoration of marine habitats & ecosystems' },
{ value: 'COMMUNITY_CAPACITY', label: 'Capacity building for coastal communities' },
{ value: 'SUSTAINABLE_FISHING', label: 'Sustainable fishing and aquaculture & blue food' },
{ value: 'CONSUMER_AWARENESS', label: 'Consumer awareness and education' },
{ value: 'OCEAN_ACIDIFICATION', label: 'Mitigation of ocean acidification' },
{ value: 'OTHER', label: 'Other' },
]
interface StepProjectProps {
form: UseFormReturn<ApplicationFormData>
}
export function StepProject({ form }: StepProjectProps) {
const { register, formState: { errors }, setValue, watch } = form
const oceanIssue = watch('oceanIssue')
const description = watch('description') || ''
return (
<WizardStepContent
title="Tell us about your project"
description="Share the details of your ocean protection initiative."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-lg space-y-6"
>
{/* Project Name */}
<div className="space-y-2">
<Label htmlFor="projectName">
Name of your project/startup <span className="text-destructive">*</span>
</Label>
<Input
id="projectName"
placeholder="Ocean Guardian AI"
{...register('projectName')}
className="h-12 text-base"
/>
{errors.projectName && (
<p className="text-sm text-destructive">{errors.projectName.message}</p>
)}
</div>
{/* Team Name (optional) */}
<div className="space-y-2">
<Label htmlFor="teamName">
Team Name <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id="teamName"
placeholder="Blue Innovation Team"
{...register('teamName')}
className="h-12 text-base"
/>
</div>
{/* Ocean Issue */}
<div className="space-y-2">
<Label>
What type of ocean issue does your project address? <span className="text-destructive">*</span>
</Label>
<Select
value={oceanIssue}
onValueChange={(value) => setValue('oceanIssue', value as OceanIssue)}
>
<SelectTrigger className="h-12 text-base">
<SelectValue placeholder="Select an ocean issue" />
</SelectTrigger>
<SelectContent>
{oceanIssueOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.oceanIssue && (
<p className="text-sm text-destructive">{errors.oceanIssue.message}</p>
)}
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">
Briefly describe your project idea and objectives <span className="text-destructive">*</span>
</Label>
<p className="text-xs text-muted-foreground">
Keep it brief - you&apos;ll have the opportunity to provide more details later.
</p>
<Textarea
id="description"
placeholder="Our project aims to..."
rows={5}
maxLength={2000}
{...register('description')}
className="text-base resize-none"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{errors.description ? (
<span className="text-destructive">{errors.description.message}</span>
) : (
'Minimum 20 characters'
)}
</span>
<span>{description.length} characters</span>
</div>
</div>
</motion.div>
</WizardStepContent>
)
}

View File

@@ -0,0 +1,213 @@
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import {
User,
Mail,
Phone,
MapPin,
Briefcase,
Waves,
Users,
GraduationCap,
Calendar,
Heart,
MessageSquare,
CheckCircle2,
} from 'lucide-react'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import type { ApplicationFormData } from '@/server/routers/application'
import { countries } from '@/components/ui/country-select'
const oceanIssueLabels: Record<string, string> = {
POLLUTION_REDUCTION: 'Reduction of pollution',
CLIMATE_MITIGATION: 'Climate change mitigation',
TECHNOLOGY_INNOVATION: 'Technology & innovations',
SUSTAINABLE_SHIPPING: 'Sustainable shipping & yachting',
BLUE_CARBON: 'Blue carbon',
HABITAT_RESTORATION: 'Marine habitat restoration',
COMMUNITY_CAPACITY: 'Coastal community capacity',
SUSTAINABLE_FISHING: 'Sustainable fishing & aquaculture',
CONSUMER_AWARENESS: 'Consumer awareness & education',
OCEAN_ACIDIFICATION: 'Ocean acidification mitigation',
OTHER: 'Other',
}
const categoryLabels: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Start-ups',
}
interface StepReviewProps {
form: UseFormReturn<ApplicationFormData>
programName: string
}
export function StepReview({ form, programName }: StepReviewProps) {
const { formState: { errors }, setValue, watch } = form
const data = watch()
const countryName = countries.find((c) => c.code === data.country)?.name || data.country
return (
<WizardStepContent
title="Review your application"
description="Please review your information before submitting."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-lg space-y-6"
>
{/* Contact Info Section */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<User className="h-4 w-4" />
Contact Information
</h3>
<div className="space-y-3 text-sm">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>{data.contactName}</span>
</div>
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
<span>{data.contactEmail}</span>
</div>
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-muted-foreground" />
<span>{data.contactPhone}</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span>{data.city ? `${data.city}, ${countryName}` : countryName}</span>
</div>
</div>
</div>
{/* Project Info Section */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<Briefcase className="h-4 w-4" />
Project Details
</h3>
<div className="space-y-3 text-sm">
<div>
<span className="text-muted-foreground">Project Name:</span>
<p className="font-medium">{data.projectName}</p>
</div>
{data.teamName && (
<div>
<span className="text-muted-foreground">Team Name:</span>
<p className="font-medium">{data.teamName}</p>
</div>
)}
<div className="flex items-center gap-2">
<Badge variant="secondary">
{categoryLabels[data.competitionCategory]}
</Badge>
</div>
<div className="flex items-center gap-2">
<Waves className="h-4 w-4 text-muted-foreground" />
<span>{oceanIssueLabels[data.oceanIssue]}</span>
</div>
<div>
<span className="text-muted-foreground">Description:</span>
<p className="mt-1 text-sm text-foreground/80 whitespace-pre-wrap">
{data.description}
</p>
</div>
</div>
</div>
{/* Team Members Section */}
{data.teamMembers && data.teamMembers.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<Users className="h-4 w-4" />
Team Members ({data.teamMembers.length})
</h3>
<div className="space-y-2">
{data.teamMembers.map((member, index) => (
<div key={index} className="flex items-center justify-between text-sm">
<div>
<span className="font-medium">{member.name}</span>
<span className="text-muted-foreground"> - {member.email}</span>
</div>
<Badge variant="outline" className="text-xs">
{member.role === 'MEMBER' ? 'Member' : 'Advisor'}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Additional Info Section */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
Additional Information
</h3>
<div className="space-y-3 text-sm">
{data.institution && (
<div className="flex items-center gap-2">
<GraduationCap className="h-4 w-4 text-muted-foreground" />
<span>{data.institution}</span>
</div>
)}
{data.startupCreatedDate && (
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>Founded: {new Date(data.startupCreatedDate).toLocaleDateString()}</span>
</div>
)}
<div className="flex items-center gap-2">
<Heart className={`h-4 w-4 ${data.wantsMentorship ? 'text-primary' : 'text-muted-foreground'}`} />
<span>
{data.wantsMentorship
? 'Interested in mentorship'
: 'Not interested in mentorship'}
</span>
</div>
{data.referralSource && (
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<span>Heard about us via: {data.referralSource}</span>
</div>
)}
</div>
</div>
{/* GDPR Consent */}
<div className="rounded-lg border-2 border-primary/20 bg-primary/5 p-4">
<div className="flex items-start gap-3">
<Checkbox
id="gdprConsent"
checked={data.gdprConsent}
onCheckedChange={(checked) => setValue('gdprConsent', checked === true)}
className="mt-1"
/>
<div className="flex-1">
<Label htmlFor="gdprConsent" className="text-sm font-medium">
I consent to the processing of my personal data <span className="text-destructive">*</span>
</Label>
<p className="mt-1 text-xs text-muted-foreground">
By submitting this application, I agree that {programName} may process my personal
data in accordance with their privacy policy. My data will be used solely for the
purpose of evaluating my application and communicating with me about the program.
</p>
</div>
</div>
{errors.gdprConsent && (
<p className="mt-2 text-sm text-destructive">{errors.gdprConsent.message}</p>
)}
</div>
</motion.div>
</WizardStepContent>
)
}

View File

@@ -0,0 +1,184 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { UseFormReturn, useFieldArray } from 'react-hook-form'
import { Plus, Trash2, Users } from 'lucide-react'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ApplicationFormData } from '@/server/routers/application'
import { TeamMemberRole } from '@prisma/client'
const roleOptions: { value: TeamMemberRole; label: string }[] = [
{ value: 'MEMBER', label: 'Team Member' },
{ value: 'ADVISOR', label: 'Advisor' },
]
interface StepTeamProps {
form: UseFormReturn<ApplicationFormData>
}
export function StepTeam({ form }: StepTeamProps) {
const { control, register, formState: { errors } } = form
const { fields, append, remove } = useFieldArray({
control,
name: 'teamMembers',
})
const addMember = () => {
append({ name: '', email: '', role: 'MEMBER', title: '' })
}
return (
<WizardStepContent
title="Your team members"
description="Add the other members of your team. They will receive an invitation to create their account."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-lg space-y-6"
>
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 py-12 text-center">
<Users className="mb-4 h-12 w-12 text-muted-foreground/50" />
<p className="text-muted-foreground">
No team members added yet.
</p>
<p className="mt-1 text-sm text-muted-foreground">
You can add team members here, or skip this step if you&apos;re applying solo.
</p>
<Button
type="button"
variant="outline"
onClick={addMember}
className="mt-4"
>
<Plus className="mr-2 h-4 w-4" />
Add Team Member
</Button>
</div>
) : (
<div className="space-y-4">
<AnimatePresence mode="popLayout">
{fields.map((field, index) => (
<motion.div
key={field.id}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="rounded-lg border bg-card p-4"
>
<div className="flex items-start justify-between mb-4">
<h4 className="font-medium text-sm text-muted-foreground">
Team Member {index + 1}
</h4>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* Name */}
<div className="space-y-2">
<Label htmlFor={`teamMembers.${index}.name`}>
Full Name <span className="text-destructive">*</span>
</Label>
<Input
id={`teamMembers.${index}.name`}
placeholder="Jane Doe"
{...register(`teamMembers.${index}.name`)}
/>
{errors.teamMembers?.[index]?.name && (
<p className="text-sm text-destructive">
{errors.teamMembers[index]?.name?.message}
</p>
)}
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor={`teamMembers.${index}.email`}>
Email <span className="text-destructive">*</span>
</Label>
<Input
id={`teamMembers.${index}.email`}
type="email"
placeholder="jane@example.com"
{...register(`teamMembers.${index}.email`)}
/>
{errors.teamMembers?.[index]?.email && (
<p className="text-sm text-destructive">
{errors.teamMembers[index]?.email?.message}
</p>
)}
</div>
{/* Role */}
<div className="space-y-2">
<Label>Role</Label>
<Select
value={form.watch(`teamMembers.${index}.role`)}
onValueChange={(value) =>
form.setValue(`teamMembers.${index}.role`, value as TeamMemberRole)
}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{roleOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Title/Position */}
<div className="space-y-2">
<Label htmlFor={`teamMembers.${index}.title`}>
Title/Position <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id={`teamMembers.${index}.title`}
placeholder="CTO, Designer, etc."
{...register(`teamMembers.${index}.title`)}
/>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
<Button
type="button"
variant="outline"
onClick={addMember}
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
Add Another Team Member
</Button>
</div>
)}
</motion.div>
</WizardStepContent>
)
}

View File

@@ -0,0 +1,132 @@
'use client'
import { motion } from 'motion/react'
import { Waves, Rocket, GraduationCap } from 'lucide-react'
import { cn } from '@/lib/utils'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { CompetitionCategory } from '@prisma/client'
interface CategoryOption {
value: CompetitionCategory
label: string
description: string
icon: typeof Rocket
}
const categories: CategoryOption[] = [
{
value: 'BUSINESS_CONCEPT',
label: 'Business Concepts',
description: 'For students and recent graduates with innovative ocean-focused business ideas',
icon: GraduationCap,
},
{
value: 'STARTUP',
label: 'Start-ups',
description: 'For established companies working on ocean protection solutions',
icon: Rocket,
},
]
interface StepWelcomeProps {
programName: string
programYear: number
value: CompetitionCategory | null
onChange: (value: CompetitionCategory) => void
}
export function StepWelcome({ programName, programYear, value, onChange }: StepWelcomeProps) {
return (
<WizardStepContent>
<div className="flex flex-col items-center text-center">
{/* Logo/Icon */}
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', duration: 0.8 }}
className="mb-8"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
<Waves className="h-10 w-10 text-primary" />
</div>
</motion.div>
{/* Welcome text */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
{programName}
</h1>
<p className="mt-2 text-xl text-primary font-semibold">
{programYear} Application
</p>
<p className="mt-4 max-w-md text-muted-foreground">
Join us in protecting our oceans. Select your category to begin.
</p>
</motion.div>
{/* Category selection */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mt-10 grid w-full max-w-2xl gap-4 md:grid-cols-2"
>
{categories.map((category) => {
const Icon = category.icon
const isSelected = value === category.value
return (
<button
key={category.value}
type="button"
onClick={() => onChange(category.value)}
className={cn(
'relative flex flex-col items-center rounded-xl border-2 p-6 text-center transition-all hover:border-primary/50 hover:shadow-md',
isSelected
? 'border-primary bg-primary/5 shadow-md'
: 'border-border bg-background'
)}
>
<div
className={cn(
'mb-4 flex h-14 w-14 items-center justify-center rounded-full transition-colors',
isSelected ? 'bg-primary text-primary-foreground' : 'bg-muted'
)}
>
<Icon className="h-7 w-7" />
</div>
<h3 className="text-lg font-semibold">{category.label}</h3>
<p className="mt-2 text-sm text-muted-foreground">
{category.description}
</p>
{isSelected && (
<motion.div
layoutId="selected-indicator"
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</motion.div>
)}
</button>
)
})}
</motion.div>
</div>
</WizardStepContent>
)
}

View File

@@ -0,0 +1,627 @@
'use client'
import { useState, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import Papa from 'papaparse'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
AlertCircle,
CheckCircle2,
Upload,
FileSpreadsheet,
ArrowRight,
ArrowLeft,
Loader2,
X,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface CSVImportFormProps {
roundId: string
roundName: string
onSuccess?: () => void
}
type Step = 'upload' | 'mapping' | 'validation' | 'importing' | 'complete'
// Required and optional fields for project import
const PROJECT_FIELDS = [
{ key: 'title', label: 'Title', required: true },
{ key: 'teamName', label: 'Team Name', required: false },
{ key: 'description', label: 'Description', required: false },
{ key: 'tags', label: 'Tags (comma-separated)', required: false },
]
interface ParsedRow {
[key: string]: string
}
interface ValidationError {
row: number
field: string
message: string
}
interface MappedProject {
title: string
teamName?: string
description?: string
tags?: string[]
metadataJson?: Record<string, unknown>
}
export function CSVImportForm({ roundId, roundName, onSuccess }: CSVImportFormProps) {
const router = useRouter()
const [step, setStep] = useState<Step>('upload')
const [file, setFile] = useState<File | null>(null)
const [csvData, setCsvData] = useState<ParsedRow[]>([])
const [csvHeaders, setCsvHeaders] = useState<string[]>([])
const [columnMapping, setColumnMapping] = useState<Record<string, string>>({})
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([])
const [importProgress, setImportProgress] = useState(0)
const importMutation = trpc.project.importCSV.useMutation({
onSuccess: () => {
setStep('complete')
onSuccess?.()
},
})
// Handle file selection and parsing
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (!selectedFile) return
setFile(selectedFile)
Papa.parse<ParsedRow>(selectedFile, {
header: true,
skipEmptyLines: true,
complete: (results) => {
if (results.data.length === 0) {
return
}
const headers = results.meta.fields || []
setCsvHeaders(headers)
setCsvData(results.data)
// Auto-map columns with exact or similar names
const autoMapping: Record<string, string> = {}
PROJECT_FIELDS.forEach((field) => {
const matchingHeader = headers.find(
(h) =>
h.toLowerCase() === field.key.toLowerCase() ||
h.toLowerCase().replace(/[_\s-]/g, '') ===
field.key.toLowerCase().replace(/[_\s-]/g, '') ||
h.toLowerCase().includes(field.key.toLowerCase())
)
if (matchingHeader) {
autoMapping[field.key] = matchingHeader
}
})
setColumnMapping(autoMapping)
setStep('mapping')
},
error: (error) => {
console.error('CSV parse error:', error)
},
})
}, [])
// Handle column mapping change
const handleMappingChange = (fieldKey: string, csvColumn: string) => {
setColumnMapping((prev) => ({
...prev,
[fieldKey]: csvColumn === '__none__' ? '' : csvColumn,
}))
}
// Validate mapped data
const validateData = useCallback((): {
valid: MappedProject[]
errors: ValidationError[]
} => {
const errors: ValidationError[] = []
const valid: MappedProject[] = []
csvData.forEach((row, index) => {
const rowNum = index + 2 // +2 for header row and 0-indexing
// Check required fields
const titleColumn = columnMapping.title
const title = titleColumn ? row[titleColumn]?.trim() : ''
if (!title) {
errors.push({
row: rowNum,
field: 'title',
message: 'Title is required',
})
return
}
// Build mapped project
const project: MappedProject = {
title,
}
// Optional fields
const teamNameColumn = columnMapping.teamName
if (teamNameColumn && row[teamNameColumn]) {
project.teamName = row[teamNameColumn].trim()
}
const descriptionColumn = columnMapping.description
if (descriptionColumn && row[descriptionColumn]) {
project.description = row[descriptionColumn].trim()
}
const tagsColumn = columnMapping.tags
if (tagsColumn && row[tagsColumn]) {
project.tags = row[tagsColumn]
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
// Store unmapped columns as metadata
const mappedColumns = new Set(Object.values(columnMapping).filter(Boolean))
const unmappedData: Record<string, unknown> = {}
Object.entries(row).forEach(([key, value]) => {
if (!mappedColumns.has(key) && value?.trim()) {
unmappedData[key] = value.trim()
}
})
if (Object.keys(unmappedData).length > 0) {
project.metadataJson = unmappedData
}
valid.push(project)
})
return { valid, errors }
}, [csvData, columnMapping])
// Proceed to validation step
const handleProceedToValidation = () => {
const { valid, errors } = validateData()
setValidationErrors(errors)
setStep('validation')
}
// Start import
const handleStartImport = async () => {
const { valid } = validateData()
if (valid.length === 0) return
setStep('importing')
setImportProgress(0)
try {
await importMutation.mutateAsync({
roundId,
projects: valid,
})
setImportProgress(100)
} catch (error) {
console.error('Import failed:', error)
setStep('validation')
}
}
// Validation summary
const validationSummary = useMemo(() => {
const { valid, errors } = validateData()
return {
total: csvData.length,
valid: valid.length,
errors: errors.length,
errorRows: [...new Set(errors.map((e) => e.row))].length,
}
}, [csvData, validateData])
// Render step content
const renderStep = () => {
switch (step) {
case 'upload':
return (
<Card>
<CardHeader>
<CardTitle>Upload CSV File</CardTitle>
<CardDescription>
Upload a CSV file containing project data to import into{' '}
<strong>{roundName}</strong>
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div
className={cn(
'border-2 border-dashed rounded-lg p-8 text-center transition-colors',
'hover:border-primary/50 cursor-pointer'
)}
onClick={() => document.getElementById('csv-file')?.click()}
>
<FileSpreadsheet className="mx-auto h-12 w-12 text-muted-foreground" />
<p className="mt-2 font-medium">
Drop your CSV file here or click to browse
</p>
<p className="text-sm text-muted-foreground">
Supports .csv files only
</p>
<Input
id="csv-file"
type="file"
accept=".csv"
onChange={handleFileSelect}
className="hidden"
/>
</div>
<div className="rounded-lg bg-muted p-4">
<p className="text-sm font-medium mb-2">Expected columns:</p>
<ul className="text-sm text-muted-foreground space-y-1">
{PROJECT_FIELDS.map((field) => (
<li key={field.key}>
<strong>{field.label}</strong>
{field.required && (
<Badge variant="destructive" className="ml-2 text-xs">
Required
</Badge>
)}
</li>
))}
</ul>
</div>
</div>
</CardContent>
</Card>
)
case 'mapping':
return (
<Card>
<CardHeader>
<CardTitle>Map Columns</CardTitle>
<CardDescription>
Map your CSV columns to project fields. {csvData.length} rows
found.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* File info */}
<div className="flex items-center gap-3 rounded-lg bg-muted p-3">
<FileSpreadsheet className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">{file?.name}</p>
<p className="text-sm text-muted-foreground">
{csvData.length} rows, {csvHeaders.length} columns
</p>
</div>
<Button
variant="ghost"
size="sm"
className="ml-auto"
onClick={() => {
setFile(null)
setCsvData([])
setCsvHeaders([])
setColumnMapping({})
setStep('upload')
}}
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Column mapping */}
<div className="space-y-4">
{PROJECT_FIELDS.map((field) => (
<div
key={field.key}
className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4"
>
<div className="sm:w-48 flex items-center gap-2">
<Label className="font-medium">{field.label}</Label>
{field.required && (
<Badge variant="destructive" className="text-xs">
Required
</Badge>
)}
</div>
<Select
value={columnMapping[field.key] || '__none__'}
onValueChange={(v) => handleMappingChange(field.key, v)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select column" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
<span className="text-muted-foreground">
-- Not mapped --
</span>
</SelectItem>
{csvHeaders.map((header) => (
<SelectItem key={header} value={header}>
{header}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
{/* Preview */}
<div className="space-y-2">
<Label className="font-medium">Preview (first 5 rows)</Label>
<div className="rounded-lg border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{PROJECT_FIELDS.filter(
(f) => columnMapping[f.key]
).map((field) => (
<TableHead key={field.key}>{field.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{csvData.slice(0, 5).map((row, index) => (
<TableRow key={index}>
{PROJECT_FIELDS.filter(
(f) => columnMapping[f.key]
).map((field) => (
<TableCell key={field.key} className="max-w-[200px] truncate">
{row[columnMapping[field.key]] || '-'}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* Actions */}
<div className="flex justify-between">
<Button
variant="outline"
onClick={() => {
setFile(null)
setCsvData([])
setCsvHeaders([])
setColumnMapping({})
setStep('upload')
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button
onClick={handleProceedToValidation}
disabled={!columnMapping.title}
>
Validate Data
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)
case 'validation':
return (
<Card>
<CardHeader>
<CardTitle>Validation Summary</CardTitle>
<CardDescription>
Review the validation results before importing
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-lg bg-muted p-4 text-center">
<p className="text-3xl font-bold">{validationSummary.total}</p>
<p className="text-sm text-muted-foreground">Total Rows</p>
</div>
<div className="rounded-lg bg-green-500/10 p-4 text-center">
<p className="text-3xl font-bold text-green-600">
{validationSummary.valid}
</p>
<p className="text-sm text-muted-foreground">Valid Projects</p>
</div>
<div className="rounded-lg bg-red-500/10 p-4 text-center">
<p className="text-3xl font-bold text-red-600">
{validationSummary.errorRows}
</p>
<p className="text-sm text-muted-foreground">Rows with Errors</p>
</div>
</div>
{/* Errors list */}
{validationErrors.length > 0 && (
<div className="space-y-2">
<Label className="font-medium text-destructive">
Validation Errors
</Label>
<div className="rounded-lg border border-destructive/50 bg-destructive/5 p-4 max-h-60 overflow-y-auto">
{validationErrors.slice(0, 20).map((error, index) => (
<div
key={index}
className="flex items-start gap-2 text-sm py-1"
>
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<span>
<strong>Row {error.row}:</strong> {error.message}
</span>
</div>
))}
{validationErrors.length > 20 && (
<p className="text-sm text-muted-foreground mt-2">
... and {validationErrors.length - 20} more errors
</p>
)}
</div>
</div>
)}
{/* Success message */}
{validationSummary.valid > 0 && validationErrors.length === 0 && (
<div className="flex items-center gap-2 rounded-lg bg-green-500/10 p-4 text-green-700">
<CheckCircle2 className="h-5 w-5" />
<span>All {validationSummary.valid} projects are valid and ready to import!</span>
</div>
)}
{/* Actions */}
<div className="flex justify-between">
<Button variant="outline" onClick={() => setStep('mapping')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Mapping
</Button>
<Button
onClick={handleStartImport}
disabled={validationSummary.valid === 0 || importMutation.isPending}
>
{importMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Import {validationSummary.valid} Projects
</Button>
</div>
{/* Import error */}
{importMutation.error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-4 text-destructive">
<AlertCircle className="h-5 w-5" />
<span>
Import failed: {importMutation.error.message}
</span>
</div>
)}
</CardContent>
</Card>
)
case 'importing':
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<p className="mt-4 font-medium">Importing projects...</p>
<p className="text-sm text-muted-foreground">
Please wait while we process your data
</p>
<Progress value={importProgress} className="mt-4 w-48" />
</CardContent>
</Card>
)
case 'complete':
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<p className="mt-4 text-xl font-semibold">Import Complete!</p>
<p className="text-muted-foreground">
Successfully imported {validationSummary.valid} projects into{' '}
<strong>{roundName}</strong>
</p>
<div className="mt-6 flex gap-3">
<Button variant="outline" onClick={() => router.back()}>
Back to Projects
</Button>
<Button
onClick={() => {
setFile(null)
setCsvData([])
setCsvHeaders([])
setColumnMapping({})
setValidationErrors([])
setStep('upload')
}}
>
Import More
</Button>
</div>
</CardContent>
</Card>
)
}
}
return (
<div className="space-y-6">
{/* Progress indicator */}
<div className="flex items-center justify-center gap-2">
{(['upload', 'mapping', 'validation', 'complete'] as const).map(
(s, index) => (
<div key={s} className="flex items-center">
{index > 0 && (
<div
className={cn(
'h-0.5 w-8 mx-1',
step === s ||
['upload', 'mapping', 'validation', 'complete'].indexOf(
step
) >
['upload', 'mapping', 'validation', 'complete'].indexOf(s)
? 'bg-primary'
: 'bg-muted'
)}
/>
)}
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium',
step === s
? 'bg-primary text-primary-foreground'
: ['upload', 'mapping', 'validation', 'complete'].indexOf(
step
) > ['upload', 'mapping', 'validation', 'complete'].indexOf(s)
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
)}
>
{index + 1}
</div>
</div>
)
)}
</div>
{renderStep()}
</div>
)
}

View File

@@ -0,0 +1,516 @@
'use client'
import { useState, useCallback } from 'react'
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 { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Plus,
Trash2,
ChevronUp,
ChevronDown,
Edit,
Eye,
GripVertical,
Check,
X,
} from 'lucide-react'
import { cn } from '@/lib/utils'
export interface Criterion {
id: string
label: string
description?: string
scale: number // 5 or 10
weight?: number
required: boolean
}
interface EvaluationFormBuilderProps {
initialCriteria?: Criterion[]
onChange: (criteria: Criterion[]) => void
disabled?: boolean
}
function generateId(): string {
return `criterion-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
function createDefaultCriterion(): Criterion {
return {
id: generateId(),
label: '',
description: '',
scale: 5,
weight: 1,
required: true,
}
}
export function EvaluationFormBuilder({
initialCriteria = [],
onChange,
disabled = false,
}: EvaluationFormBuilderProps) {
const [criteria, setCriteria] = useState<Criterion[]>(initialCriteria)
const [editingId, setEditingId] = useState<string | null>(null)
const [editDraft, setEditDraft] = useState<Criterion | null>(null)
// Update parent when criteria change
const updateCriteria = useCallback(
(newCriteria: Criterion[]) => {
setCriteria(newCriteria)
onChange(newCriteria)
},
[onChange]
)
// Add new criterion
const addCriterion = useCallback(() => {
const newCriterion = createDefaultCriterion()
const newCriteria = [...criteria, newCriterion]
updateCriteria(newCriteria)
setEditingId(newCriterion.id)
setEditDraft(newCriterion)
}, [criteria, updateCriteria])
// Delete criterion
const deleteCriterion = useCallback(
(id: string) => {
updateCriteria(criteria.filter((c) => c.id !== id))
if (editingId === id) {
setEditingId(null)
setEditDraft(null)
}
},
[criteria, editingId, updateCriteria]
)
// Move criterion up/down
const moveCriterion = useCallback(
(id: string, direction: 'up' | 'down') => {
const index = criteria.findIndex((c) => c.id === id)
if (index === -1) return
const newIndex = direction === 'up' ? index - 1 : index + 1
if (newIndex < 0 || newIndex >= criteria.length) return
const newCriteria = [...criteria]
const [removed] = newCriteria.splice(index, 1)
newCriteria.splice(newIndex, 0, removed)
updateCriteria(newCriteria)
},
[criteria, updateCriteria]
)
// Start editing
const startEditing = useCallback((criterion: Criterion) => {
setEditingId(criterion.id)
setEditDraft({ ...criterion })
}, [])
// Cancel editing
const cancelEditing = useCallback(() => {
// If it's a new criterion with no label, remove it
if (editDraft && !editDraft.label.trim()) {
updateCriteria(criteria.filter((c) => c.id !== editDraft.id))
}
setEditingId(null)
setEditDraft(null)
}, [editDraft, criteria, updateCriteria])
// Save editing
const saveEditing = useCallback(() => {
if (!editDraft || !editDraft.label.trim()) return
updateCriteria(
criteria.map((c) => (c.id === editDraft.id ? editDraft : c))
)
setEditingId(null)
setEditDraft(null)
}, [editDraft, criteria, updateCriteria])
// Update edit draft
const updateDraft = useCallback(
(updates: Partial<Criterion>) => {
if (!editDraft) return
setEditDraft({ ...editDraft, ...updates })
},
[editDraft]
)
return (
<div className="space-y-4">
{/* Criteria list */}
{criteria.length > 0 ? (
<div className="space-y-2">
{criteria.map((criterion, index) => {
const isEditing = editingId === criterion.id
return (
<div
key={criterion.id}
className={cn(
'rounded-lg border transition-colors',
isEditing ? 'border-primary bg-muted/30' : 'bg-background',
disabled && 'opacity-60'
)}
>
{isEditing && editDraft ? (
// Edit mode
<div className="p-4 space-y-4">
<div className="space-y-2">
<Label htmlFor={`label-${criterion.id}`}>Label *</Label>
<Input
id={`label-${criterion.id}`}
value={editDraft.label}
onChange={(e) => updateDraft({ label: e.target.value })}
placeholder="e.g., Innovation"
disabled={disabled}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor={`description-${criterion.id}`}>
Description (optional)
</Label>
<Textarea
id={`description-${criterion.id}`}
value={editDraft.description || ''}
onChange={(e) => updateDraft({ description: e.target.value })}
placeholder="Help jurors understand this criterion..."
rows={2}
maxLength={500}
disabled={disabled}
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={`scale-${criterion.id}`}>Scale</Label>
<Select
value={String(editDraft.scale)}
onValueChange={(v) => updateDraft({ scale: parseInt(v) })}
disabled={disabled}
>
<SelectTrigger id={`scale-${criterion.id}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">1-5</SelectItem>
<SelectItem value="10">1-10</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor={`weight-${criterion.id}`}>
Weight (optional)
</Label>
<Input
id={`weight-${criterion.id}`}
type="number"
min={0}
step={0.1}
value={editDraft.weight ?? ''}
onChange={(e) =>
updateDraft({
weight: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder="1"
disabled={disabled}
/>
</div>
<div className="space-y-2">
<Label>Required</Label>
<div className="flex items-center h-10">
<Switch
checked={editDraft.required}
onCheckedChange={(checked) =>
updateDraft({ required: checked })
}
disabled={disabled}
/>
</div>
</div>
</div>
{/* Edit actions */}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={cancelEditing}
disabled={disabled}
>
<X className="mr-1 h-4 w-4" />
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={saveEditing}
disabled={disabled || !editDraft.label.trim()}
>
<Check className="mr-1 h-4 w-4" />
Save
</Button>
</div>
</div>
) : (
// View mode
<div className="flex items-center gap-3 p-3">
{/* Drag handle / position indicator */}
<div className="text-muted-foreground/50">
<GripVertical className="h-4 w-4" />
</div>
{/* Criterion info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium truncate">
{criterion.label || '(Untitled)'}
</span>
<Badge variant="secondary" className="shrink-0 text-xs">
1-{criterion.scale}
</Badge>
{criterion.weight && criterion.weight !== 1 && (
<Badge variant="outline" className="shrink-0 text-xs">
{criterion.weight}x
</Badge>
)}
{criterion.required && (
<Badge variant="default" className="shrink-0 text-xs">
Required
</Badge>
)}
</div>
{criterion.description && (
<p className="text-sm text-muted-foreground truncate mt-0.5">
{criterion.description}
</p>
)}
</div>
{/* Actions */}
{!disabled && (
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Move criterion up"
onClick={() => moveCriterion(criterion.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Move criterion down"
onClick={() => moveCriterion(criterion.id, 'down')}
disabled={index === criteria.length - 1}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Edit criterion"
onClick={() => startEditing(criterion)}
>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
aria-label="Delete criterion"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete criterion?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{criterion.label}&quot;?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteCriterion(criterion.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</div>
)}
</div>
)
})}
</div>
) : (
<div className="rounded-lg border border-dashed p-8 text-center">
<p className="text-muted-foreground">
No evaluation criteria defined yet.
</p>
</div>
)}
{/* Actions */}
{!disabled && (
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={addCriterion}
disabled={editingId !== null}
>
<Plus className="mr-1 h-4 w-4" />
Add Criterion
</Button>
{criteria.length > 0 && (
<PreviewDialog criteria={criteria} />
)}
</div>
)}
</div>
)
}
// Preview dialog showing how the evaluation form will look
function PreviewDialog({ criteria }: { criteria: Criterion[] }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="ghost" size="sm">
<Eye className="mr-1 h-4 w-4" />
Preview
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Evaluation Form Preview</DialogTitle>
<DialogDescription>
This is how the evaluation form will appear to jurors.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{criteria.map((criterion) => (
<Card key={criterion.id}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
{criterion.label}
{criterion.required && (
<Badge variant="destructive" className="text-xs">
Required
</Badge>
)}
</CardTitle>
{criterion.description && (
<CardDescription>{criterion.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-4">1</span>
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary/30 rounded-full"
style={{ width: '50%' }}
/>
</div>
<span className="text-xs text-muted-foreground w-4">
{criterion.scale}
</span>
</div>
<div className="flex gap-1 flex-wrap">
{Array.from({ length: criterion.scale }, (_, i) => i + 1).map(
(num) => (
<div
key={num}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium flex items-center justify-center',
num <= Math.ceil(criterion.scale / 2)
? 'bg-primary/20 text-primary'
: 'bg-muted'
)}
>
{num}
</div>
)
)}
</div>
</div>
</CardContent>
</Card>
))}
{criteria.length === 0 && (
<p className="text-center text-muted-foreground py-8">
No criteria to preview.
</p>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,600 @@
'use client'
import { useState, useEffect, useCallback, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useDebouncedCallback } from 'use-debounce'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Loader2,
Save,
Send,
CheckCircle2,
AlertCircle,
Clock,
Star,
ThumbsUp,
ThumbsDown,
} from 'lucide-react'
import { cn } from '@/lib/utils'
// Define criterion type from the evaluation form JSON
interface Criterion {
id: string
label: string
description?: string
scale: number // max value (e.g., 5 or 10)
weight?: number
required?: boolean
}
interface EvaluationFormProps {
assignmentId: string
evaluationId: string | null
projectTitle: string
criteria: Criterion[]
initialData?: {
criterionScoresJson: Record<string, number> | null
globalScore: number | null
binaryDecision: boolean | null
feedbackText: string | null
status: string
}
isVotingOpen: boolean
deadline?: Date | null
}
const createEvaluationSchema = (criteria: Criterion[]) =>
z.object({
criterionScores: z.record(z.number()),
globalScore: z.number().int().min(1).max(10),
binaryDecision: z.boolean(),
feedbackText: z.string().min(10, 'Please provide at least 10 characters of feedback'),
})
type EvaluationFormData = z.infer<ReturnType<typeof createEvaluationSchema>>
export function EvaluationForm({
assignmentId,
evaluationId,
projectTitle,
criteria,
initialData,
isVotingOpen,
deadline,
}: EvaluationFormProps) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [lastSaved, setLastSaved] = useState<Date | null>(null)
// Initialize criterion scores with existing data or defaults
const defaultCriterionScores: Record<string, number> = {}
criteria.forEach((c) => {
defaultCriterionScores[c.id] = initialData?.criterionScoresJson?.[c.id] ?? Math.ceil(c.scale / 2)
})
const form = useForm<EvaluationFormData>({
resolver: zodResolver(createEvaluationSchema(criteria)),
defaultValues: {
criterionScores: defaultCriterionScores,
globalScore: initialData?.globalScore ?? 5,
binaryDecision: initialData?.binaryDecision ?? false,
feedbackText: initialData?.feedbackText ?? '',
},
mode: 'onChange',
})
const { watch, handleSubmit, control, formState } = form
const { errors, isValid, isDirty } = formState
// tRPC mutations
const startEvaluation = trpc.evaluation.start.useMutation()
const autosave = trpc.evaluation.autosave.useMutation()
const submit = trpc.evaluation.submit.useMutation()
const utils = trpc.useUtils()
// State to track the current evaluation ID (might be created on first autosave)
const [currentEvaluationId, setCurrentEvaluationId] = useState<string | null>(evaluationId)
// Create evaluation if it doesn't exist
useEffect(() => {
if (!currentEvaluationId && isVotingOpen) {
startEvaluation.mutate(
{ assignmentId },
{
onSuccess: (data) => {
setCurrentEvaluationId(data.id)
},
}
)
}
}, [assignmentId, currentEvaluationId, isVotingOpen])
// Debounced autosave function
const debouncedAutosave = useDebouncedCallback(
async (data: EvaluationFormData) => {
if (!currentEvaluationId || !isVotingOpen) return
setAutosaveStatus('saving')
try {
await autosave.mutateAsync({
id: currentEvaluationId,
criterionScoresJson: data.criterionScores,
globalScore: data.globalScore,
binaryDecision: data.binaryDecision,
feedbackText: data.feedbackText,
})
setAutosaveStatus('saved')
setLastSaved(new Date())
// Reset to idle after a few seconds
setTimeout(() => setAutosaveStatus('idle'), 3000)
} catch (error) {
console.error('Autosave failed:', error)
setAutosaveStatus('error')
}
},
3000 // 3 second debounce
)
// Watch form values and trigger autosave
const watchedValues = watch()
useEffect(() => {
if (isDirty && isVotingOpen) {
debouncedAutosave(watchedValues)
}
}, [watchedValues, isDirty, isVotingOpen, debouncedAutosave])
// Submit handler
const onSubmit = async (data: EvaluationFormData) => {
if (!currentEvaluationId) return
try {
await submit.mutateAsync({
id: currentEvaluationId,
criterionScoresJson: data.criterionScores,
globalScore: data.globalScore,
binaryDecision: data.binaryDecision,
feedbackText: data.feedbackText,
})
// Invalidate queries and redirect
utils.assignment.myAssignments.invalidate()
startTransition(() => {
router.push(`/jury/projects/${assignmentId.split('-')[0]}/evaluation`)
router.refresh()
})
} catch (error) {
console.error('Submit failed:', error)
}
}
const isSubmitted = initialData?.status === 'SUBMITTED' || initialData?.status === 'LOCKED'
const isReadOnly = isSubmitted || !isVotingOpen
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Status bar */}
<div className="sticky top-0 z-10 -mx-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 py-3 border-b">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<h2 className="font-semibold truncate max-w-[200px] sm:max-w-none">
{projectTitle}
</h2>
<AutosaveIndicator status={autosaveStatus} lastSaved={lastSaved} />
</div>
{!isReadOnly && (
<div className="flex items-center gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
disabled={!isValid || submit.isPending}
>
{submit.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Submit Evaluation
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Submit Evaluation?</AlertDialogTitle>
<AlertDialogDescription>
Once submitted, you cannot edit your evaluation. Please review
your scores and feedback before confirming.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleSubmit(onSubmit)}
disabled={submit.isPending}
>
{submit.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Confirm Submit
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
{isReadOnly && (
<Badge variant="secondary">
<CheckCircle2 className="mr-1 h-3 w-3" />
{isSubmitted ? 'Submitted' : 'Read Only'}
</Badge>
)}
</div>
</div>
{/* Criteria scoring */}
{criteria.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Evaluation Criteria</CardTitle>
<CardDescription>
Rate the project on each criterion below
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{criteria.map((criterion) => (
<CriterionField
key={criterion.id}
criterion={criterion}
control={control}
disabled={isReadOnly}
/>
))}
</CardContent>
</Card>
)}
{/* Global score */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Overall Score</CardTitle>
<CardDescription>
Rate the project overall on a scale of 1 to 10
</CardDescription>
</CardHeader>
<CardContent>
<Controller
name="globalScore"
control={control}
render={({ field }) => (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Poor</span>
<span className="text-4xl font-bold">{field.value}</span>
<span className="text-sm text-muted-foreground">Excellent</span>
</div>
<Slider
min={1}
max={10}
step={1}
value={[field.value]}
onValueChange={(v) => field.onChange(v[0])}
disabled={isReadOnly}
className="py-4"
/>
<div className="flex justify-between">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
<button
key={num}
type="button"
onClick={() => !isReadOnly && field.onChange(num)}
disabled={isReadOnly}
className={cn(
'w-8 h-8 rounded-full text-sm font-medium transition-colors',
field.value === num
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80',
isReadOnly && 'opacity-50 cursor-not-allowed'
)}
>
{num}
</button>
))}
</div>
</div>
)}
/>
</CardContent>
</Card>
{/* Binary decision */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Recommendation</CardTitle>
<CardDescription>
Do you recommend this project to advance to the next round?
</CardDescription>
</CardHeader>
<CardContent>
<Controller
name="binaryDecision"
control={control}
render={({ field }) => (
<div className="flex gap-4">
<Button
type="button"
variant={field.value ? 'default' : 'outline'}
className={cn(
'flex-1 h-20',
field.value && 'bg-green-600 hover:bg-green-700'
)}
onClick={() => !isReadOnly && field.onChange(true)}
disabled={isReadOnly}
>
<ThumbsUp className="mr-2 h-6 w-6" />
Yes, Recommend
</Button>
<Button
type="button"
variant={!field.value ? 'default' : 'outline'}
className={cn(
'flex-1 h-20',
!field.value && 'bg-red-600 hover:bg-red-700'
)}
onClick={() => !isReadOnly && field.onChange(false)}
disabled={isReadOnly}
>
<ThumbsDown className="mr-2 h-6 w-6" />
No, Do Not Recommend
</Button>
</div>
)}
/>
</CardContent>
</Card>
{/* Feedback text */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Written Feedback</CardTitle>
<CardDescription>
Provide constructive feedback for this project (minimum 10 characters)
</CardDescription>
</CardHeader>
<CardContent>
<Controller
name="feedbackText"
control={control}
render={({ field }) => (
<div className="space-y-2">
<Textarea
{...field}
placeholder="Share your thoughts on the project's strengths, weaknesses, and potential..."
rows={6}
maxLength={5000}
disabled={isReadOnly}
className={cn(
errors.feedbackText && 'border-destructive'
)}
/>
<div className="flex items-center justify-between">
{errors.feedbackText ? (
<p className="text-sm text-destructive">
{errors.feedbackText.message}
</p>
) : (
<p className="text-sm text-muted-foreground">
{field.value.length} characters
</p>
)}
</div>
</div>
)}
/>
</CardContent>
</Card>
{/* Error display */}
{submit.error && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 py-4">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">
{submit.error.message || 'Failed to submit evaluation. Please try again.'}
</p>
</CardContent>
</Card>
)}
{/* Bottom submit button for mobile */}
{!isReadOnly && (
<div className="flex justify-end pb-safe">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
size="lg"
disabled={!isValid || submit.isPending}
className="w-full sm:w-auto"
>
{submit.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Submit Evaluation
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Submit Evaluation?</AlertDialogTitle>
<AlertDialogDescription>
Once submitted, you cannot edit your evaluation. Please review
your scores and feedback before confirming.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleSubmit(onSubmit)}
disabled={submit.isPending}
>
{submit.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Confirm Submit
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</form>
)
}
// Criterion field component
function CriterionField({
criterion,
control,
disabled,
}: {
criterion: Criterion
control: any
disabled: boolean
}) {
return (
<Controller
name={`criterionScores.${criterion.id}`}
control={control}
render={({ field }) => (
<div className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-base font-medium">{criterion.label}</Label>
{criterion.description && (
<p className="text-sm text-muted-foreground">
{criterion.description}
</p>
)}
</div>
<Badge variant="secondary" className="shrink-0">
{field.value}/{criterion.scale}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-4">1</span>
<Slider
min={1}
max={criterion.scale}
step={1}
value={[field.value]}
onValueChange={(v) => field.onChange(v[0])}
disabled={disabled}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-4">{criterion.scale}</span>
</div>
{/* Visual rating buttons */}
<div className="flex gap-1 flex-wrap">
{Array.from({ length: criterion.scale }, (_, i) => i + 1).map((num) => (
<button
key={num}
type="button"
onClick={() => !disabled && field.onChange(num)}
disabled={disabled}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
field.value === num
? 'bg-primary text-primary-foreground'
: field.value > num
? 'bg-primary/20 text-primary'
: 'bg-muted hover:bg-muted/80',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{num}
</button>
))}
</div>
</div>
)}
/>
)
}
// Autosave indicator component
function AutosaveIndicator({
status,
lastSaved,
}: {
status: 'idle' | 'saving' | 'saved' | 'error'
lastSaved: Date | null
}) {
if (status === 'idle' && lastSaved) {
return (
<span className="text-xs text-muted-foreground hidden sm:inline">
Saved
</span>
)
}
if (status === 'saving') {
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="hidden sm:inline">Saving...</span>
</span>
)
}
if (status === 'saved') {
return (
<span className="flex items-center gap-1 text-xs text-green-600">
<CheckCircle2 className="h-3 w-3" />
<span className="hidden sm:inline">Saved</span>
</span>
)
}
if (status === 'error') {
return (
<span className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="h-3 w-3" />
<span className="hidden sm:inline">Save failed</span>
</span>
)
}
return null
}

View File

@@ -0,0 +1,326 @@
'use client'
import * as React from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { ArrowLeft, ArrowRight, Check, Loader2 } from 'lucide-react'
export interface WizardStep {
id: string
title: string
description?: string
isOptional?: boolean
}
interface FormWizardContextValue {
currentStep: number
totalSteps: number
steps: WizardStep[]
goToStep: (step: number) => void
nextStep: () => void
prevStep: () => void
isFirstStep: boolean
isLastStep: boolean
canGoNext: boolean
setCanGoNext: (can: boolean) => void
}
const FormWizardContext = React.createContext<FormWizardContextValue | null>(null)
export function useFormWizard() {
const context = React.useContext(FormWizardContext)
if (!context) {
throw new Error('useFormWizard must be used within a FormWizard')
}
return context
}
interface FormWizardProps {
steps: WizardStep[]
children: React.ReactNode
onComplete?: () => void | Promise<void>
isSubmitting?: boolean
submitLabel?: string
className?: string
showStepIndicator?: boolean
allowStepNavigation?: boolean
}
export function FormWizard({
steps,
children,
onComplete,
isSubmitting = false,
submitLabel = 'Submit',
className,
showStepIndicator = true,
allowStepNavigation = false,
}: FormWizardProps) {
const [currentStep, setCurrentStep] = React.useState(0)
const [canGoNext, setCanGoNext] = React.useState(true)
const [direction, setDirection] = React.useState(0) // -1 for back, 1 for forward
const totalSteps = steps.length
const isFirstStep = currentStep === 0
const isLastStep = currentStep === totalSteps - 1
const goToStep = React.useCallback((step: number) => {
if (step >= 0 && step < totalSteps) {
setDirection(step > currentStep ? 1 : -1)
setCurrentStep(step)
}
}, [currentStep, totalSteps])
const nextStep = React.useCallback(() => {
if (currentStep < totalSteps - 1) {
setDirection(1)
setCurrentStep((prev) => prev + 1)
}
}, [currentStep, totalSteps])
const prevStep = React.useCallback(() => {
if (currentStep > 0) {
setDirection(-1)
setCurrentStep((prev) => prev - 1)
}
}, [currentStep])
const handleNext = async () => {
if (isLastStep && onComplete) {
await onComplete()
} else {
nextStep()
}
}
const contextValue: FormWizardContextValue = {
currentStep,
totalSteps,
steps,
goToStep,
nextStep,
prevStep,
isFirstStep,
isLastStep,
canGoNext,
setCanGoNext,
}
const childrenArray = React.Children.toArray(children)
const currentChild = childrenArray[currentStep]
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 100 : -100,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 100 : -100,
opacity: 0,
}),
}
return (
<FormWizardContext.Provider value={contextValue}>
<div className={cn('flex min-h-[600px] flex-col', className)}>
{showStepIndicator && (
<StepIndicator
steps={steps}
currentStep={currentStep}
allowNavigation={allowStepNavigation}
onStepClick={allowStepNavigation ? goToStep : undefined}
/>
)}
<div className="relative flex-1 overflow-hidden">
<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="absolute inset-0"
>
<div className="h-full px-1 py-6">
{currentChild}
</div>
</motion.div>
</AnimatePresence>
</div>
<div className="flex items-center justify-between border-t pt-6">
<Button
type="button"
variant="ghost"
onClick={prevStep}
disabled={isFirstStep || isSubmitting}
className={cn(isFirstStep && 'invisible')}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button
type="button"
onClick={handleNext}
disabled={!canGoNext || isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : isLastStep ? (
<>
<Check className="mr-2 h-4 w-4" />
{submitLabel}
</>
) : (
<>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
</FormWizardContext.Provider>
)
}
interface StepIndicatorProps {
steps: WizardStep[]
currentStep: number
allowNavigation?: boolean
onStepClick?: (step: number) => void
}
export function StepIndicator({
steps,
currentStep,
allowNavigation = false,
onStepClick,
}: StepIndicatorProps) {
return (
<div className="mb-8">
{/* Progress bar */}
<div className="mb-4">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>Step {currentStep + 1} of {steps.length}</span>
<span>{Math.round(((currentStep + 1) / steps.length) * 100)}% complete</span>
</div>
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-secondary">
<motion.div
className="h-full bg-gradient-to-r from-primary to-primary/80"
initial={{ width: 0 }}
animate={{ width: `${((currentStep + 1) / steps.length) * 100}%` }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
/>
</div>
</div>
{/* Step indicators */}
<div className="flex items-center justify-between">
{steps.map((step, index) => {
const isCompleted = index < currentStep
const isCurrent = index === currentStep
const isClickable = allowNavigation && (isCompleted || isCurrent)
return (
<React.Fragment key={step.id}>
<button
type="button"
onClick={() => isClickable && onStepClick?.(index)}
disabled={!isClickable}
className={cn(
'flex flex-col items-center gap-2',
isClickable && 'cursor-pointer',
!isClickable && 'cursor-default'
)}
>
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full border-2 text-sm font-semibold transition-colors',
isCompleted && 'border-primary bg-primary text-primary-foreground',
isCurrent && 'border-primary text-primary',
!isCompleted && !isCurrent && 'border-muted text-muted-foreground'
)}
>
{isCompleted ? (
<Check className="h-5 w-5" />
) : (
<span>{index + 1}</span>
)}
</div>
<span
className={cn(
'hidden text-xs font-medium md:block',
isCurrent && 'text-primary',
!isCurrent && 'text-muted-foreground'
)}
>
{step.title}
</span>
</button>
{index < steps.length - 1 && (
<div
className={cn(
'h-0.5 flex-1 mx-2',
index < currentStep ? 'bg-primary' : 'bg-muted'
)}
/>
)}
</React.Fragment>
)
})}
</div>
</div>
)
}
interface WizardStepContentProps {
children: React.ReactNode
title?: string
description?: string
className?: string
}
export function WizardStepContent({
children,
title,
description,
className,
}: WizardStepContentProps) {
return (
<div className={cn('flex h-full flex-col', className)}>
{(title || description) && (
<div className="mb-8 text-center">
{title && (
<h2 className="text-2xl font-semibold tracking-tight md:text-3xl">
{title}
</h2>
)}
{description && (
<p className="mt-2 text-muted-foreground">
{description}
</p>
)}
</div>
)}
<div className="flex-1">
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,489 @@
'use client'
import { useState } from 'react'
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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { toast } from 'sonner'
import {
CheckCircle,
AlertCircle,
Loader2,
ArrowRight,
ArrowLeft,
Database,
} from 'lucide-react'
interface NotionImportFormProps {
roundId: string
roundName: string
onSuccess?: () => void
}
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
export function NotionImportForm({
roundId,
roundName,
onSuccess,
}: NotionImportFormProps) {
const [step, setStep] = useState<Step>('connect')
const [apiKey, setApiKey] = useState('')
const [databaseId, setDatabaseId] = useState('')
const [isConnecting, setIsConnecting] = useState(false)
const [connectionError, setConnectionError] = useState<string | null>(null)
// Mapping state
const [mappings, setMappings] = useState({
title: '',
teamName: '',
description: '',
tags: '',
})
const [includeUnmapped, setIncludeUnmapped] = useState(true)
// Results
const [importResults, setImportResults] = useState<{
imported: number
skipped: number
errors: Array<{ recordId: string; error: string }>
} | null>(null)
const testConnection = trpc.notionImport.testConnection.useMutation()
const { data: schema, refetch: refetchSchema } =
trpc.notionImport.getDatabaseSchema.useQuery(
{ apiKey, databaseId },
{ enabled: false }
)
const { data: preview, refetch: refetchPreview } =
trpc.notionImport.previewData.useQuery(
{ apiKey, databaseId, limit: 5 },
{ enabled: false }
)
const importMutation = trpc.notionImport.importProjects.useMutation()
const handleConnect = async () => {
if (!apiKey || !databaseId) {
toast.error('Please enter both API key and database ID')
return
}
setIsConnecting(true)
setConnectionError(null)
try {
const result = await testConnection.mutateAsync({ apiKey })
if (!result.success) {
setConnectionError(result.error || 'Connection failed')
return
}
// Fetch schema
await refetchSchema()
setStep('map')
} catch (error) {
setConnectionError(
error instanceof Error ? error.message : 'Connection failed'
)
} finally {
setIsConnecting(false)
}
}
const handlePreview = async () => {
if (!mappings.title) {
toast.error('Please map the Title field')
return
}
await refetchPreview()
setStep('preview')
}
const handleImport = async () => {
setStep('import')
try {
const result = await importMutation.mutateAsync({
apiKey,
databaseId,
roundId,
mappings: {
title: mappings.title,
teamName: mappings.teamName || undefined,
description: mappings.description || undefined,
tags: mappings.tags || undefined,
},
includeUnmappedInMetadata: includeUnmapped,
})
setImportResults(result)
setStep('complete')
if (result.imported > 0) {
toast.success(`Imported ${result.imported} projects`)
onSuccess?.()
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Import failed'
)
setStep('preview')
}
}
const properties = schema?.properties || []
return (
<div className="space-y-6">
{/* Progress indicator */}
<div className="flex items-center gap-2">
{['connect', 'map', 'preview', 'import', 'complete'].map((s, i) => (
<div key={s} className="flex items-center">
{i > 0 && <div className="w-8 h-0.5 bg-muted mx-1" />}
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
step === s
? 'bg-primary text-primary-foreground'
: ['connect', 'map', 'preview', 'import', 'complete'].indexOf(step) > i
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
{i + 1}
</div>
</div>
))}
</div>
{/* Step 1: Connect */}
{step === 'connect' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Connect to Notion
</CardTitle>
<CardDescription>
Enter your Notion API key and database ID to connect
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="apiKey">Notion API Key</Label>
<Input
id="apiKey"
type="password"
placeholder="secret_..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Create an integration at{' '}
<a
href="https://www.notion.so/my-integrations"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
notion.so/my-integrations
</a>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="databaseId">Database ID</Label>
<Input
id="databaseId"
placeholder="abc123..."
value={databaseId}
onChange={(e) => setDatabaseId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
The ID from your Notion database URL
</p>
</div>
{connectionError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Connection Failed</AlertTitle>
<AlertDescription>{connectionError}</AlertDescription>
</Alert>
)}
<Button
onClick={handleConnect}
disabled={isConnecting || !apiKey || !databaseId}
>
{isConnecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Connect
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
)}
{/* Step 2: Map columns */}
{step === 'map' && (
<Card>
<CardHeader>
<CardTitle>Map Columns</CardTitle>
<CardDescription>
Map Notion properties to project fields. Database: {schema?.title}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>
Title <span className="text-destructive">*</span>
</Label>
<Select
value={mappings.title}
onValueChange={(v) => setMappings((m) => ({ ...m, title: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select property" />
</SelectTrigger>
<SelectContent>
{properties.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}{' '}
<span className="text-muted-foreground">({p.type})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Team Name</Label>
<Select
value={mappings.teamName}
onValueChange={(v) =>
setMappings((m) => ({ ...m, teamName: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select property (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{properties.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Select
value={mappings.description}
onValueChange={(v) =>
setMappings((m) => ({ ...m, description: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select property (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{properties.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Tags</Label>
<Select
value={mappings.tags}
onValueChange={(v) => setMappings((m) => ({ ...m, tags: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select property (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{properties
.filter((p) => p.type === 'multi_select' || p.type === 'select')
.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="includeUnmapped"
checked={includeUnmapped}
onCheckedChange={(c) => setIncludeUnmapped(!!c)}
/>
<Label htmlFor="includeUnmapped" className="font-normal">
Store unmapped columns in metadata
</Label>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep('connect')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={handlePreview} disabled={!mappings.title}>
Preview
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Preview */}
{step === 'preview' && (
<Card>
<CardHeader>
<CardTitle>Preview Import</CardTitle>
<CardDescription>
Review the first {preview?.count || 0} records before importing
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="px-4 py-2 text-left font-medium">Title</th>
<th className="px-4 py-2 text-left font-medium">Team</th>
<th className="px-4 py-2 text-left font-medium">Tags</th>
</tr>
</thead>
<tbody>
{preview?.records.map((record, i) => (
<tr key={i} className="border-t">
<td className="px-4 py-2">
{String(record.properties[mappings.title] || '-')}
</td>
<td className="px-4 py-2">
{mappings.teamName
? String(record.properties[mappings.teamName] || '-')
: '-'}
</td>
<td className="px-4 py-2">
{mappings.tags && record.properties[mappings.tags]
? (
record.properties[mappings.tags] as string[]
).map((tag, j) => (
<Badge key={j} variant="secondary" className="mr-1">
{tag}
</Badge>
))
: '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Ready to import</AlertTitle>
<AlertDescription>
This will import all records from the Notion database into{' '}
<strong>{roundName}</strong>.
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep('map')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={handleImport}>
Import All Records
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 4: Importing */}
{step === 'import' && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">Importing projects...</p>
<p className="text-sm text-muted-foreground">
Please wait while we import your data from Notion
</p>
</CardContent>
</Card>
)}
{/* Step 5: Complete */}
{step === 'complete' && importResults && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
<p className="text-lg font-medium">Import Complete</p>
<div className="mt-4 text-center">
<p className="text-2xl font-bold text-green-600">
{importResults.imported}
</p>
<p className="text-sm text-muted-foreground">projects imported</p>
</div>
{importResults.skipped > 0 && (
<p className="mt-2 text-sm text-muted-foreground">
{importResults.skipped} records skipped
</p>
)}
{importResults.errors.length > 0 && (
<div className="mt-4 w-full max-w-md">
<p className="text-sm font-medium text-destructive mb-2">
Errors ({importResults.errors.length}):
</p>
<div className="max-h-32 overflow-y-auto text-xs text-muted-foreground">
{importResults.errors.slice(0, 5).map((e, i) => (
<p key={i}>
{e.recordId}: {e.error}
</p>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,513 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
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 { Alert, AlertDescription } from '@/components/ui/alert'
import { Filter, ClipboardCheck, Zap, Info } from 'lucide-react'
import {
type FilteringRoundSettings,
type EvaluationRoundSettings,
type LiveEventRoundSettings,
defaultFilteringSettings,
defaultEvaluationSettings,
defaultLiveEventSettings,
roundTypeLabels,
roundTypeDescriptions,
} from '@/types/round-settings'
interface RoundTypeSettingsProps {
roundType: 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'
onRoundTypeChange: (type: 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT') => void
settings: Record<string, unknown>
onSettingsChange: (settings: Record<string, unknown>) => void
}
const roundTypeIcons = {
FILTERING: Filter,
EVALUATION: ClipboardCheck,
LIVE_EVENT: Zap,
}
export function RoundTypeSettings({
roundType,
onRoundTypeChange,
settings,
onSettingsChange,
}: RoundTypeSettingsProps) {
const Icon = roundTypeIcons[roundType]
// Get typed settings with defaults
const getFilteringSettings = (): FilteringRoundSettings => ({
...defaultFilteringSettings,
...(settings as Partial<FilteringRoundSettings>),
})
const getEvaluationSettings = (): EvaluationRoundSettings => ({
...defaultEvaluationSettings,
...(settings as Partial<EvaluationRoundSettings>),
})
const getLiveEventSettings = (): LiveEventRoundSettings => ({
...defaultLiveEventSettings,
...(settings as Partial<LiveEventRoundSettings>),
})
const updateSetting = <T extends Record<string, unknown>>(
key: keyof T,
value: T[keyof T]
) => {
onSettingsChange({ ...settings, [key]: value })
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon className="h-5 w-5" />
Round Type & Configuration
</CardTitle>
<CardDescription>
Configure the type and behavior for this round
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Round Type Selector */}
<div className="space-y-2">
<Label>Round Type</Label>
<Select value={roundType} onValueChange={(v) => onRoundTypeChange(v as typeof roundType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(['FILTERING', 'EVALUATION', 'LIVE_EVENT'] as const).map((type) => {
const TypeIcon = roundTypeIcons[type]
return (
<SelectItem key={type} value={type}>
<div className="flex items-center gap-2">
<TypeIcon className="h-4 w-4" />
{roundTypeLabels[type]}
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{roundTypeDescriptions[roundType]}
</p>
</div>
{/* Type-specific settings */}
{roundType === 'FILTERING' && (
<FilteringSettings
settings={getFilteringSettings()}
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
/>
)}
{roundType === 'EVALUATION' && (
<EvaluationSettings
settings={getEvaluationSettings()}
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
/>
)}
{roundType === 'LIVE_EVENT' && (
<LiveEventSettings
settings={getLiveEventSettings()}
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
/>
)}
</CardContent>
</Card>
)
}
// Filtering Round Settings
function FilteringSettings({
settings,
onChange,
}: {
settings: FilteringRoundSettings
onChange: (settings: FilteringRoundSettings) => void
}) {
return (
<div className="space-y-6 border-t pt-4">
<h4 className="font-medium">Filtering Settings</h4>
{/* Target Advancing */}
<div className="space-y-2">
<Label htmlFor="targetAdvancing">Target Projects to Advance</Label>
<Input
id="targetAdvancing"
type="number"
min="1"
value={settings.targetAdvancing}
onChange={(e) =>
onChange({ ...settings, targetAdvancing: parseInt(e.target.value) || 0 })
}
/>
<p className="text-xs text-muted-foreground">
The target number of projects to advance to the next round
</p>
</div>
{/* Auto-elimination */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Auto-Elimination</Label>
<p className="text-sm text-muted-foreground">
Automatically flag projects below threshold
</p>
</div>
<Switch
checked={settings.autoEliminationEnabled}
onCheckedChange={(v) =>
onChange({ ...settings, autoEliminationEnabled: v })
}
/>
</div>
{settings.autoEliminationEnabled && (
<div className="ml-6 space-y-4 border-l-2 pl-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="threshold">Score Threshold</Label>
<Input
id="threshold"
type="number"
min="1"
max="10"
step="0.5"
value={settings.autoEliminationThreshold}
onChange={(e) =>
onChange({
...settings,
autoEliminationThreshold: parseFloat(e.target.value) || 0,
})
}
/>
<p className="text-xs text-muted-foreground">
Projects averaging below this score will be flagged
</p>
</div>
<div className="space-y-2">
<Label htmlFor="minReviews">Minimum Reviews</Label>
<Input
id="minReviews"
type="number"
min="1"
value={settings.autoEliminationMinReviews}
onChange={(e) =>
onChange({
...settings,
autoEliminationMinReviews: parseInt(e.target.value) || 1,
})
}
/>
<p className="text-xs text-muted-foreground">
Min reviews before auto-elimination applies
</p>
</div>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Auto-elimination only flags projects for review. Final decisions require
admin approval.
</AlertDescription>
</Alert>
</div>
)}
</div>
{/* Display Options */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Display Options</h5>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex items-center justify-between">
<Label htmlFor="showAverage">Show Average Score</Label>
<Switch
id="showAverage"
checked={settings.showAverageScore}
onCheckedChange={(v) =>
onChange({ ...settings, showAverageScore: v })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="showRanking">Show Ranking</Label>
<Switch
id="showRanking"
checked={settings.showRanking}
onCheckedChange={(v) =>
onChange({ ...settings, showRanking: v })
}
/>
</div>
</div>
</div>
</div>
)
}
// Evaluation Round Settings
function EvaluationSettings({
settings,
onChange,
}: {
settings: EvaluationRoundSettings
onChange: (settings: EvaluationRoundSettings) => void
}) {
return (
<div className="space-y-6 border-t pt-4">
<h4 className="font-medium">Evaluation Settings</h4>
{/* Target Finalists */}
<div className="space-y-2">
<Label htmlFor="targetFinalists">Target Finalists</Label>
<Input
id="targetFinalists"
type="number"
min="1"
value={settings.targetFinalists}
onChange={(e) =>
onChange({ ...settings, targetFinalists: parseInt(e.target.value) || 0 })
}
/>
<p className="text-xs text-muted-foreground">
The target number of finalists to select
</p>
</div>
{/* Requirements */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Requirements</h5>
<div className="flex items-center justify-between">
<div>
<Label>Require All Criteria</Label>
<p className="text-sm text-muted-foreground">
Jury must score all criteria before submission
</p>
</div>
<Switch
checked={settings.requireAllCriteria}
onCheckedChange={(v) =>
onChange({ ...settings, requireAllCriteria: v })
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Detailed Criteria Required</Label>
<p className="text-sm text-muted-foreground">
Use detailed evaluation criteria
</p>
</div>
<Switch
checked={settings.detailedCriteriaRequired}
onCheckedChange={(v) =>
onChange({ ...settings, detailedCriteriaRequired: v })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="minFeedback">Minimum Feedback Length</Label>
<Input
id="minFeedback"
type="number"
min="0"
value={settings.minimumFeedbackLength}
onChange={(e) =>
onChange({
...settings,
minimumFeedbackLength: parseInt(e.target.value) || 0,
})
}
/>
<p className="text-xs text-muted-foreground">
Minimum characters for feedback comments (0 = optional)
</p>
</div>
</div>
{/* Display Options */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Display Options</h5>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex items-center justify-between">
<Label htmlFor="showAverage">Show Average Score</Label>
<Switch
id="showAverage"
checked={settings.showAverageScore}
onCheckedChange={(v) =>
onChange({ ...settings, showAverageScore: v })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="showRanking">Show Ranking</Label>
<Switch
id="showRanking"
checked={settings.showRanking}
onCheckedChange={(v) =>
onChange({ ...settings, showRanking: v })
}
/>
</div>
</div>
</div>
</div>
)
}
// Live Event Round Settings
function LiveEventSettings({
settings,
onChange,
}: {
settings: LiveEventRoundSettings
onChange: (settings: LiveEventRoundSettings) => void
}) {
return (
<div className="space-y-6 border-t pt-4">
<h4 className="font-medium">Live Event Settings</h4>
{/* Presentation */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Presentation</h5>
<div className="space-y-2">
<Label htmlFor="duration">Presentation Duration (minutes)</Label>
<Input
id="duration"
type="number"
min="1"
max="60"
value={settings.presentationDurationMinutes}
onChange={(e) =>
onChange({
...settings,
presentationDurationMinutes: parseInt(e.target.value) || 5,
})
}
/>
</div>
</div>
{/* Voting */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Voting</h5>
<div className="space-y-2">
<Label htmlFor="votingWindow">Voting Window (seconds)</Label>
<Input
id="votingWindow"
type="number"
min="10"
max="300"
value={settings.votingWindowSeconds}
onChange={(e) =>
onChange({
...settings,
votingWindowSeconds: parseInt(e.target.value) || 30,
})
}
/>
<p className="text-xs text-muted-foreground">
Duration of the voting window after each presentation
</p>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Allow Vote Change</Label>
<p className="text-sm text-muted-foreground">
Allow jury to change their vote during the window
</p>
</div>
<Switch
checked={settings.allowVoteChange}
onCheckedChange={(v) =>
onChange({ ...settings, allowVoteChange: v })
}
/>
</div>
</div>
{/* Display */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Display</h5>
<div className="flex items-center justify-between">
<div>
<Label>Show Live Scores</Label>
<p className="text-sm text-muted-foreground">
Display scores in real-time during the event
</p>
</div>
<Switch
checked={settings.showLiveScores}
onCheckedChange={(v) =>
onChange({ ...settings, showLiveScores: v })
}
/>
</div>
<div className="space-y-2">
<Label>Display Mode</Label>
<Select
value={settings.displayMode}
onValueChange={(v) =>
onChange({
...settings,
displayMode: v as 'SCORES' | 'RANKING' | 'NONE',
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SCORES">Show Scores</SelectItem>
<SelectItem value="RANKING">Show Ranking</SelectItem>
<SelectItem value="NONE">Hide Until End</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
How results are displayed on the public screen
</p>
</div>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Presentation order can be configured in the Live Voting section once the round
is activated.
</AlertDescription>
</Alert>
</div>
)
}

View File

@@ -0,0 +1,516 @@
'use client'
import { useState } from 'react'
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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { toast } from 'sonner'
import {
CheckCircle,
AlertCircle,
Loader2,
ArrowRight,
ArrowLeft,
FileText,
} from 'lucide-react'
interface TypeformImportFormProps {
roundId: string
roundName: string
onSuccess?: () => void
}
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
export function TypeformImportForm({
roundId,
roundName,
onSuccess,
}: TypeformImportFormProps) {
const [step, setStep] = useState<Step>('connect')
const [apiKey, setApiKey] = useState('')
const [formId, setFormId] = useState('')
const [isConnecting, setIsConnecting] = useState(false)
const [connectionError, setConnectionError] = useState<string | null>(null)
// Mapping state
const [mappings, setMappings] = useState({
title: '',
teamName: '',
description: '',
tags: '',
email: '',
})
const [includeUnmapped, setIncludeUnmapped] = useState(true)
// Results
const [importResults, setImportResults] = useState<{
imported: number
skipped: number
errors: Array<{ responseId: string; error: string }>
} | null>(null)
const testConnection = trpc.typeformImport.testConnection.useMutation()
const { data: schema, refetch: refetchSchema } =
trpc.typeformImport.getFormSchema.useQuery(
{ apiKey, formId },
{ enabled: false }
)
const { data: preview, refetch: refetchPreview } =
trpc.typeformImport.previewResponses.useQuery(
{ apiKey, formId, limit: 5 },
{ enabled: false }
)
const importMutation = trpc.typeformImport.importProjects.useMutation()
const handleConnect = async () => {
if (!apiKey || !formId) {
toast.error('Please enter both API key and form ID')
return
}
setIsConnecting(true)
setConnectionError(null)
try {
const result = await testConnection.mutateAsync({ apiKey })
if (!result.success) {
setConnectionError(result.error || 'Connection failed')
return
}
// Fetch schema
await refetchSchema()
setStep('map')
} catch (error) {
setConnectionError(
error instanceof Error ? error.message : 'Connection failed'
)
} finally {
setIsConnecting(false)
}
}
const handlePreview = async () => {
if (!mappings.title) {
toast.error('Please map the Title field')
return
}
await refetchPreview()
setStep('preview')
}
const handleImport = async () => {
setStep('import')
try {
const result = await importMutation.mutateAsync({
apiKey,
formId,
roundId,
mappings: {
title: mappings.title,
teamName: mappings.teamName || undefined,
description: mappings.description || undefined,
tags: mappings.tags || undefined,
email: mappings.email || undefined,
},
includeUnmappedInMetadata: includeUnmapped,
})
setImportResults(result)
setStep('complete')
if (result.imported > 0) {
toast.success(`Imported ${result.imported} projects`)
onSuccess?.()
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Import failed'
)
setStep('preview')
}
}
const fields = schema?.fields || []
return (
<div className="space-y-6">
{/* Progress indicator */}
<div className="flex items-center gap-2">
{['connect', 'map', 'preview', 'import', 'complete'].map((s, i) => (
<div key={s} className="flex items-center">
{i > 0 && <div className="w-8 h-0.5 bg-muted mx-1" />}
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
step === s
? 'bg-primary text-primary-foreground'
: ['connect', 'map', 'preview', 'import', 'complete'].indexOf(step) > i
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
{i + 1}
</div>
</div>
))}
</div>
{/* Step 1: Connect */}
{step === 'connect' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Connect to Typeform
</CardTitle>
<CardDescription>
Enter your Typeform API key and form ID to connect
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="apiKey">Typeform API Key</Label>
<Input
id="apiKey"
type="password"
placeholder="tfp_..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Get your API key from{' '}
<a
href="https://admin.typeform.com/user/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Typeform Admin
</a>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="formId">Form ID</Label>
<Input
id="formId"
placeholder="abc123..."
value={formId}
onChange={(e) => setFormId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
The ID from your Typeform URL (e.g., typeform.com/to/ABC123)
</p>
</div>
{connectionError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Connection Failed</AlertTitle>
<AlertDescription>{connectionError}</AlertDescription>
</Alert>
)}
<Button
onClick={handleConnect}
disabled={isConnecting || !apiKey || !formId}
>
{isConnecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Connect
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
)}
{/* Step 2: Map columns */}
{step === 'map' && (
<Card>
<CardHeader>
<CardTitle>Map Questions</CardTitle>
<CardDescription>
Map Typeform questions to project fields. Form: {schema?.title}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>
Title <span className="text-destructive">*</span>
</Label>
<Select
value={mappings.title}
onValueChange={(v) => setMappings((m) => ({ ...m, title: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select question" />
</SelectTrigger>
<SelectContent>
{fields.map((f) => (
<SelectItem key={f.id} value={f.title}>
<div className="flex flex-col">
<span className="truncate max-w-[200px]">{f.title}</span>
<span className="text-xs text-muted-foreground">
{f.type}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Team Name</Label>
<Select
value={mappings.teamName}
onValueChange={(v) =>
setMappings((m) => ({ ...m, teamName: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select question (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{fields.map((f) => (
<SelectItem key={f.id} value={f.title}>
{f.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Select
value={mappings.description}
onValueChange={(v) =>
setMappings((m) => ({ ...m, description: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select question (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{fields
.filter((f) => f.type === 'long_text' || f.type === 'short_text')
.map((f) => (
<SelectItem key={f.id} value={f.title}>
{f.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Tags</Label>
<Select
value={mappings.tags}
onValueChange={(v) => setMappings((m) => ({ ...m, tags: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select question (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{fields
.filter(
(f) =>
f.type === 'multiple_choice' || f.type === 'dropdown'
)
.map((f) => (
<SelectItem key={f.id} value={f.title}>
{f.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Email</Label>
<Select
value={mappings.email}
onValueChange={(v) => setMappings((m) => ({ ...m, email: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select question (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{fields
.filter((f) => f.type === 'email')
.map((f) => (
<SelectItem key={f.id} value={f.title}>
{f.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="includeUnmapped"
checked={includeUnmapped}
onCheckedChange={(c) => setIncludeUnmapped(!!c)}
/>
<Label htmlFor="includeUnmapped" className="font-normal">
Store unmapped answers in metadata
</Label>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep('connect')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={handlePreview} disabled={!mappings.title}>
Preview
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Preview */}
{step === 'preview' && (
<Card>
<CardHeader>
<CardTitle>Preview Import</CardTitle>
<CardDescription>
Review the first {preview?.count || 0} responses before importing
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="px-4 py-2 text-left font-medium">Title</th>
<th className="px-4 py-2 text-left font-medium">Team</th>
<th className="px-4 py-2 text-left font-medium">Email</th>
</tr>
</thead>
<tbody>
{preview?.records.map((record, i) => (
<tr key={i} className="border-t">
<td className="px-4 py-2 truncate max-w-[200px]">
{String(record[mappings.title] || '-')}
</td>
<td className="px-4 py-2">
{mappings.teamName
? String(record[mappings.teamName] || '-')
: '-'}
</td>
<td className="px-4 py-2">
{mappings.email
? String(record[mappings.email] || '-')
: '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Ready to import</AlertTitle>
<AlertDescription>
This will import all responses from the Typeform into{' '}
<strong>{roundName}</strong>.
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep('map')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={handleImport}>
Import All Responses
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 4: Importing */}
{step === 'import' && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">Importing responses...</p>
<p className="text-sm text-muted-foreground">
Please wait while we import your data from Typeform
</p>
</CardContent>
</Card>
)}
{/* Step 5: Complete */}
{step === 'complete' && importResults && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
<p className="text-lg font-medium">Import Complete</p>
<div className="mt-4 text-center">
<p className="text-2xl font-bold text-green-600">
{importResults.imported}
</p>
<p className="text-sm text-muted-foreground">projects imported</p>
</div>
{importResults.skipped > 0 && (
<p className="mt-2 text-sm text-muted-foreground">
{importResults.skipped} responses skipped
</p>
)}
{importResults.errors.length > 0 && (
<div className="mt-4 w-full max-w-md">
<p className="text-sm font-medium text-destructive mb-2">
Errors ({importResults.errors.length}):
</p>
<div className="max-h-32 overflow-y-auto text-xs text-muted-foreground">
{importResults.errors.slice(0, 5).map((e, i) => (
<p key={i}>
{e.responseId}: {e.error}
</p>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
)
}