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:
676
src/app/(public)/apply/[slug]/wizard/page.tsx
Normal file
676
src/app/(public)/apply/[slug]/wizard/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user