Files
MOPC-Portal/src/app/(auth)/onboarding/applicant-wizard.tsx
Matt 49e706f2cf
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m50s
feat: applicant onboarding, bulk invite, team management enhancements
- 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>
2026-03-02 10:11:11 +01:00

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&apos;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 &quot;{onboardingCtx.projectTitle}&quot;. 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&apos;ll be redirected to your dashboard shortly.
</p>
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</CardContent>
)}
</Card>
</AnimatedCard>
</div>
)
}