All checks were successful
Build and Push Docker Image / build (push) Successful in 9m50s
- Add nationality/institution fields to User model with migration - Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences) - Project logo upload from applicant context with team membership verification - APPLICANT redirects in set-password, onboarding, and auth layout - Mask evaluation round names as "Evaluation Round 1/2/..." for applicants - Extend inviteTeamMember with nationality/country/institution/sendInvite fields - Admin getApplicants query with search/filter/pagination - Admin bulkInviteApplicants mutation with token generation and emails - Applicants tab on Members page with bulk select and floating invite bar Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
600 lines
21 KiB
TypeScript
600 lines
21 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo, useEffect } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { trpc } from '@/lib/trpc/client'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { CountrySelect } from '@/components/ui/country-select'
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { cn } from '@/lib/utils'
|
|
import { toast } from 'sonner'
|
|
import { AvatarUpload } from '@/components/shared/avatar-upload'
|
|
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import {
|
|
User,
|
|
Bell,
|
|
CheckCircle,
|
|
Loader2,
|
|
ArrowRight,
|
|
ArrowLeft,
|
|
Camera,
|
|
Globe,
|
|
FileText,
|
|
Building2,
|
|
Flag,
|
|
ImageIcon,
|
|
} from 'lucide-react'
|
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
|
|
|
type Step =
|
|
| 'name'
|
|
| 'photo'
|
|
| 'nationality'
|
|
| 'country'
|
|
| 'institution'
|
|
| 'bio'
|
|
| 'logo'
|
|
| 'preferences'
|
|
| 'complete'
|
|
|
|
type ApplicantWizardProps = {
|
|
userData: {
|
|
id: string
|
|
name: string | null
|
|
email: string
|
|
role: string
|
|
country: string | null
|
|
nationality: string | null
|
|
institution: string | null
|
|
bio: string | null
|
|
profileImageKey: string | null
|
|
notificationPreference: string
|
|
}
|
|
avatarUrl: string | null | undefined
|
|
refetchUser: () => void
|
|
}
|
|
|
|
export function ApplicantOnboardingWizard({
|
|
userData,
|
|
avatarUrl,
|
|
refetchUser,
|
|
}: ApplicantWizardProps) {
|
|
const router = useRouter()
|
|
const [step, setStep] = useState<Step>('name')
|
|
const [initialized, setInitialized] = useState(false)
|
|
|
|
// Form state
|
|
const [name, setName] = useState('')
|
|
const [nationality, setNationality] = useState('')
|
|
const [country, setCountry] = useState('')
|
|
const [institution, setInstitution] = useState('')
|
|
const [bio, setBio] = useState('')
|
|
const [notificationPreference, setNotificationPreference] = useState<
|
|
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
|
|
>('EMAIL')
|
|
|
|
// Fetch onboarding context (project info)
|
|
const { data: onboardingCtx } = trpc.applicant.getOnboardingContext.useQuery()
|
|
const { data: logoUrl, refetch: refetchLogo } = trpc.applicant.getProjectLogoUrl.useQuery(
|
|
{ projectId: onboardingCtx?.projectId ?? '' },
|
|
{ enabled: !!onboardingCtx?.projectId }
|
|
)
|
|
|
|
// Initialize form with user data
|
|
useEffect(() => {
|
|
if (userData && !initialized) {
|
|
if (userData.name) setName(userData.name)
|
|
if (userData.country) setCountry(userData.country)
|
|
if (userData.nationality) setNationality(userData.nationality)
|
|
if (userData.institution) setInstitution(userData.institution)
|
|
if (userData.bio) setBio(userData.bio)
|
|
if (userData.notificationPreference) {
|
|
setNotificationPreference(userData.notificationPreference as typeof notificationPreference)
|
|
}
|
|
setInitialized(true)
|
|
}
|
|
}, [userData, initialized])
|
|
|
|
// Prefill institution from project if user hasn't set one
|
|
useEffect(() => {
|
|
if (onboardingCtx?.institution && !institution && initialized) {
|
|
setInstitution(onboardingCtx.institution)
|
|
}
|
|
}, [onboardingCtx, institution, initialized])
|
|
|
|
const utils = trpc.useUtils()
|
|
const completeOnboarding = trpc.user.completeOnboarding.useMutation({
|
|
onSuccess: () => utils.user.me.invalidate(),
|
|
})
|
|
|
|
const steps: Step[] = useMemo(() => {
|
|
const base: Step[] = [
|
|
'name',
|
|
'photo',
|
|
'nationality',
|
|
'country',
|
|
'institution',
|
|
'bio',
|
|
]
|
|
// Only show logo step if applicant has a project
|
|
if (onboardingCtx?.projectId) {
|
|
base.push('logo')
|
|
}
|
|
base.push('preferences', 'complete')
|
|
return base
|
|
}, [onboardingCtx?.projectId])
|
|
|
|
const currentIndex = steps.indexOf(step)
|
|
const totalVisibleSteps = steps.length - 1
|
|
|
|
const goNext = () => {
|
|
if (step === 'name' && !name.trim()) {
|
|
toast.error('Please enter your name')
|
|
return
|
|
}
|
|
const nextIndex = currentIndex + 1
|
|
if (nextIndex < steps.length) {
|
|
setStep(steps[nextIndex])
|
|
}
|
|
}
|
|
|
|
const goBack = () => {
|
|
const prevIndex = currentIndex - 1
|
|
if (prevIndex >= 0) {
|
|
setStep(steps[prevIndex])
|
|
}
|
|
}
|
|
|
|
const handleComplete = async () => {
|
|
try {
|
|
await completeOnboarding.mutateAsync({
|
|
name,
|
|
country: country || undefined,
|
|
nationality: nationality || undefined,
|
|
institution: institution || undefined,
|
|
bio: bio || undefined,
|
|
notificationPreference,
|
|
})
|
|
setStep('complete')
|
|
toast.success('Welcome to MOPC!')
|
|
|
|
setTimeout(() => {
|
|
router.push('/applicant')
|
|
}, 2000)
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : 'Failed to complete onboarding')
|
|
}
|
|
}
|
|
|
|
const stepLabels: Record<Step, string> = {
|
|
name: 'Name',
|
|
photo: 'Photo',
|
|
nationality: 'Nationality',
|
|
country: 'Residence',
|
|
institution: 'Institution',
|
|
bio: 'About',
|
|
logo: 'Logo',
|
|
preferences: 'Settings',
|
|
complete: 'Done',
|
|
}
|
|
|
|
return (
|
|
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat">
|
|
<AnimatedCard>
|
|
<Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto overflow-x-hidden shadow-2xl">
|
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
|
|
|
{/* Progress indicator */}
|
|
{step !== 'complete' && (
|
|
<div className="px-6 pt-6">
|
|
<div className="flex items-center gap-2">
|
|
{steps.slice(0, -1).map((s, i) => (
|
|
<div key={s} className="flex items-center flex-1">
|
|
<div
|
|
className={`h-2 flex-1 rounded-full transition-colors ${
|
|
i < currentIndex
|
|
? 'bg-primary'
|
|
: i === currentIndex
|
|
? 'bg-primary/60'
|
|
: 'bg-muted'
|
|
}`}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
{steps.slice(0, -1).map((s, i) => (
|
|
<div key={s} className="flex-1 text-center">
|
|
<span
|
|
className={cn(
|
|
'text-[10px]',
|
|
i <= currentIndex ? 'text-primary font-medium' : 'text-muted-foreground'
|
|
)}
|
|
>
|
|
{stepLabels[s]}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mt-2">
|
|
Step {currentIndex + 1} of {totalVisibleSteps}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step: Name */}
|
|
{step === 'name' && (
|
|
<>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<User className="h-5 w-5 text-primary" />
|
|
Welcome to MOPC
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Let's get your profile set up. What should we call you?
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Full Name</Label>
|
|
<Input
|
|
id="name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="Enter your full name"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<Button onClick={goNext} className="w-full" disabled={!name.trim()}>
|
|
Continue
|
|
<ArrowRight className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</CardContent>
|
|
</>
|
|
)}
|
|
|
|
{/* Step: Profile Photo */}
|
|
{step === 'photo' && (
|
|
<>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Camera className="h-5 w-5 text-primary" />
|
|
Profile Photo
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Add a profile photo so others can recognize you. This step is optional.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex justify-center">
|
|
<AvatarUpload
|
|
user={{
|
|
name: userData?.name,
|
|
email: userData?.email,
|
|
profileImageKey: userData?.profileImageKey,
|
|
}}
|
|
currentAvatarUrl={avatarUrl}
|
|
onUploadComplete={() => refetchUser()}
|
|
/>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground text-center">
|
|
Click the avatar to upload a new photo.
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back
|
|
</Button>
|
|
<Button onClick={goNext} className="flex-1">
|
|
{avatarUrl ? 'Continue' : 'Skip for now'}
|
|
<ArrowRight className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</>
|
|
)}
|
|
|
|
{/* Step: Nationality */}
|
|
{step === 'nationality' && (
|
|
<>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Flag className="h-5 w-5 text-primary" />
|
|
Nationality
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Select your nationality.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="nationality">Nationality</Label>
|
|
<CountrySelect
|
|
value={nationality}
|
|
onChange={setNationality}
|
|
placeholder="Select your nationality"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back
|
|
</Button>
|
|
<Button onClick={goNext} className="flex-1">
|
|
{nationality ? 'Continue' : 'Skip for now'}
|
|
<ArrowRight className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</>
|
|
)}
|
|
|
|
{/* Step: Country of Residence */}
|
|
{step === 'country' && (
|
|
<>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Globe className="h-5 w-5 text-primary" />
|
|
Country of Residence
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Where are you currently based?
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="country">Country</Label>
|
|
<CountrySelect
|
|
value={country}
|
|
onChange={setCountry}
|
|
placeholder="Select your country"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back
|
|
</Button>
|
|
<Button onClick={goNext} className="flex-1">
|
|
{country ? 'Continue' : 'Skip for now'}
|
|
<ArrowRight className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</>
|
|
)}
|
|
|
|
{/* Step: Institution */}
|
|
{step === 'institution' && (
|
|
<>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Building2 className="h-5 w-5 text-primary" />
|
|
Institution
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Your organization or institution name.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="institution">Institution / Organization</Label>
|
|
<Input
|
|
id="institution"
|
|
value={institution}
|
|
onChange={(e) => setInstitution(e.target.value)}
|
|
placeholder="e.g., Ocean Research Institute"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back
|
|
</Button>
|
|
<Button onClick={goNext} className="flex-1">
|
|
{institution ? 'Continue' : 'Skip for now'}
|
|
<ArrowRight className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</>
|
|
)}
|
|
|
|
{/* Step: Bio */}
|
|
{step === 'bio' && (
|
|
<>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<FileText className="h-5 w-5 text-primary" />
|
|
About You
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Tell us a bit about yourself and your work. (Optional)
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="bio">Bio</Label>
|
|
<Textarea
|
|
id="bio"
|
|
value={bio}
|
|
onChange={(e) => setBio(e.target.value)}
|
|
placeholder="e.g., Marine biologist working on coral reef conservation..."
|
|
rows={4}
|
|
maxLength={500}
|
|
/>
|
|
<p className="text-xs text-muted-foreground text-right">
|
|
{bio.length}/500 characters
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back
|
|
</Button>
|
|
<Button onClick={goNext} className="flex-1">
|
|
{bio ? 'Continue' : 'Skip for now'}
|
|
<ArrowRight className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</>
|
|
)}
|
|
|
|
{/* Step: Project Logo */}
|
|
{step === 'logo' && onboardingCtx?.projectId && (
|
|
<>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<ImageIcon className="h-5 w-5 text-primary" />
|
|
Project Logo
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Upload a logo for "{onboardingCtx.projectTitle}". This step is optional.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex justify-center">
|
|
<ProjectLogoUpload
|
|
projectId={onboardingCtx.projectId}
|
|
currentLogoUrl={logoUrl}
|
|
onUploadComplete={() => refetchLogo()}
|
|
/>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground text-center">
|
|
Click the image area to upload a logo.
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back
|
|
</Button>
|
|
<Button onClick={goNext} className="flex-1">
|
|
{logoUrl ? 'Continue' : 'Skip for now'}
|
|
<ArrowRight className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</>
|
|
)}
|
|
|
|
{/* Step: Notification Preferences */}
|
|
{step === 'preferences' && (
|
|
<>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Bell className="h-5 w-5 text-primary" />
|
|
Notification Preferences
|
|
</CardTitle>
|
|
<CardDescription>
|
|
How would you like to receive notifications?
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="notifications">Notification Channel</Label>
|
|
<Select
|
|
value={notificationPreference}
|
|
onValueChange={(v) =>
|
|
setNotificationPreference(v as typeof notificationPreference)
|
|
}
|
|
>
|
|
<SelectTrigger id="notifications">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="EMAIL">Email only</SelectItem>
|
|
<SelectItem value="NONE">No notifications</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="rounded-lg border p-4 bg-muted/50">
|
|
<h4 className="font-medium mb-2">Summary</h4>
|
|
<div className="space-y-1 text-sm">
|
|
<p>
|
|
<span className="text-muted-foreground">Name:</span> {name}
|
|
</p>
|
|
{nationality && (
|
|
<p>
|
|
<span className="text-muted-foreground">Nationality:</span> {nationality}
|
|
</p>
|
|
)}
|
|
{country && (
|
|
<p>
|
|
<span className="text-muted-foreground">Country:</span> {country}
|
|
</p>
|
|
)}
|
|
{institution && (
|
|
<p>
|
|
<span className="text-muted-foreground">Institution:</span> {institution}
|
|
</p>
|
|
)}
|
|
{bio && (
|
|
<p>
|
|
<span className="text-muted-foreground">Bio:</span>{' '}
|
|
{bio.length > 50 ? `${bio.substring(0, 50)}...` : bio}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back
|
|
</Button>
|
|
<Button
|
|
onClick={handleComplete}
|
|
className="flex-1"
|
|
disabled={completeOnboarding.isPending}
|
|
>
|
|
{completeOnboarding.isPending ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<CheckCircle className="mr-2 h-4 w-4" />
|
|
)}
|
|
Complete Setup
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</>
|
|
)}
|
|
|
|
{/* Step: Complete */}
|
|
{step === 'complete' && (
|
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
<div className="rounded-2xl bg-emerald-50 p-4 mb-4 animate-in zoom-in-50 duration-500">
|
|
<CheckCircle className="h-12 w-12 text-green-600" />
|
|
</div>
|
|
<h2 className="text-xl font-semibold mb-2 animate-in fade-in slide-in-from-bottom-2 duration-500 delay-200">
|
|
Welcome, {name}!
|
|
</h2>
|
|
<p className="text-muted-foreground text-center mb-4">
|
|
Your profile is all set up. You'll be redirected to your dashboard shortly.
|
|
</p>
|
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
</AnimatedCard>
|
|
</div>
|
|
)
|
|
}
|