Add background filtering jobs, improved date picker, AI reasoning display

- Implement background job system for AI filtering to avoid HTTP timeouts
- Add FilteringJob model to track progress of long-running filtering operations
- Add real-time progress polling for filtering operations on round details page
- Create custom DateTimePicker component with calendar popup (no year picker hassle)
- Fix round date persistence bug (refetchOnWindowFocus was resetting form state)
- Integrate filtering controls into round details page for filtering rounds
- Display AI reasoning for flagged/filtered projects in results table
- Add onboarding system scaffolding (schema, routes, basic UI)
- Allow setting round dates in the past for manual overrides

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 19:48:41 +01:00
parent 8be740a4fb
commit e2782b2b19
24 changed files with 3692 additions and 443 deletions

View File

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