Files
MOPC-Portal/src/app/(auth)/onboarding/page.tsx

726 lines
27 KiB
TypeScript
Raw Normal View History

'use client'
import { useState, useMemo, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/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 { PhoneInput } from '@/components/ui/phone-input'
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 { Slider } from '@/components/ui/slider'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { ExpertiseSelect } from '@/components/shared/expertise-select'
import { AvatarUpload } from '@/components/shared/avatar-upload'
import { Textarea } from '@/components/ui/textarea'
import {
User,
Phone,
Tags,
Bell,
CheckCircle,
Loader2,
ArrowRight,
ArrowLeft,
Camera,
Globe,
FileText,
Scale,
} from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'jury' | 'preferences' | 'complete'
type JuryPref = {
juryGroupMemberId: string
juryGroupName: string
currentCap: number
allowCapAdjustment: boolean
allowRatioAdjustment: boolean
selfServiceCap: number | null
selfServiceRatio: number | null
}
export default function OnboardingPage() {
const router = useRouter()
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const [step, setStep] = useState<Step>('name')
const [initialized, setInitialized] = useState(false)
// Form state
const [name, setName] = useState('')
const [country, setCountry] = useState('')
const [bio, setBio] = useState('')
const [phoneNumber, setPhoneNumber] = useState('')
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
const [lockedTags, setLockedTags] = useState<string[]>([])
const [notificationPreference, setNotificationPreference] = useState<
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
>('EMAIL')
const [juryPrefs, setJuryPrefs] = useState<Map<string, { cap?: number; ratio?: number }>>(new Map())
// Fetch current user data only after session is hydrated
const { data: userData, isLoading: userLoading, refetch: refetchUser } = trpc.user.me.useQuery(
undefined,
{ enabled: isAuthenticated }
)
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(
undefined,
{ enabled: isAuthenticated }
)
// Initialize form with user data
useEffect(() => {
if (userData && !initialized) {
// Pre-fill name if available
if (userData.name) {
setName(userData.name)
}
// Pre-fill country if available
if (userData.country) {
setCountry(userData.country)
}
// Pre-fill bio if available
if (userData.bio) {
setBio(userData.bio)
}
// Pre-fill phone if available
if (userData.phoneNumber) {
setPhoneNumber(userData.phoneNumber)
}
// Set admin-preset tags as both locked and selected
if (userData.expertiseTags && userData.expertiseTags.length > 0) {
setLockedTags(userData.expertiseTags)
setExpertiseTags(userData.expertiseTags)
}
// Pre-fill notification preference if available
if (userData.notificationPreference) {
setNotificationPreference(userData.notificationPreference)
}
setInitialized(true)
}
}, [userData, initialized])
// Fetch jury onboarding context
const { data: onboardingCtx } = trpc.user.getOnboardingContext.useQuery(
undefined,
{ enabled: isAuthenticated }
)
const juryMemberships: JuryPref[] = onboardingCtx?.memberships ?? []
const hasJuryStep = onboardingCtx?.hasSelfServiceOptions ?? false
// Fetch feature flags only after session is hydrated
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery(
undefined,
{ enabled: isAuthenticated }
)
const whatsappEnabled = featureFlags?.whatsappEnabled ?? false
const utils = trpc.useUtils()
const completeOnboarding = trpc.user.completeOnboarding.useMutation({
onSuccess: () => utils.user.me.invalidate(),
})
// Dynamic steps based on WhatsApp availability and jury self-service
const steps: Step[] = useMemo(() => {
const base: Step[] = ['name', 'photo', 'country', 'bio']
if (whatsappEnabled) base.push('phone')
base.push('tags')
if (hasJuryStep) base.push('jury')
base.push('preferences', 'complete')
return base
}, [whatsappEnabled, hasJuryStep])
const currentIndex = steps.indexOf(step)
const totalVisibleSteps = steps.length - 1 // Exclude 'complete' from count
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 {
// Build jury preferences from state
const juryPreferences = juryMemberships
.map((m) => {
const pref = juryPrefs.get(m.juryGroupMemberId)
if (!pref) return null
return {
juryGroupMemberId: m.juryGroupMemberId,
selfServiceCap: pref.cap,
selfServiceRatio: pref.ratio,
}
})
.filter(Boolean) as Array<{
juryGroupMemberId: string
selfServiceCap?: number
selfServiceRatio?: number
}>
await completeOnboarding.mutateAsync({
name,
country: country || undefined,
bio: bio || undefined,
phoneNumber: phoneNumber || undefined,
expertiseTags,
notificationPreference,
juryPreferences: juryPreferences.length > 0 ? juryPreferences : undefined,
})
setStep('complete')
toast.success('Welcome to MOPC!')
// Redirect after a short delay based on user role
setTimeout(() => {
const role = userData?.role
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
router.push('/admin')
} else if (role === 'MENTOR') {
router.push('/mentor')
} else if (role === 'OBSERVER') {
router.push('/observer')
} else {
router.push('/jury')
}
}, 2000)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to complete onboarding')
}
}
// Show loading while session hydrates or fetching user data
if (sessionStatus === 'loading' || userLoading || !initialized) {
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 shadow-2xl overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4" />
<p className="text-muted-foreground">Loading your profile...</p>
</CardContent>
</Card>
</AnimatedCard>
</div>
)
}
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-hidden shadow-2xl">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
{/* Progress indicator */}
<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>
{/* Step labels */}
<div className="flex items-center gap-2 mt-1">
{steps.slice(0, -1).map((s, i) => {
const labels: Record<string, string> = {
name: 'Name',
photo: 'Photo',
country: 'Country',
bio: 'About',
phone: 'Phone',
tags: 'Expertise',
jury: 'Jury',
preferences: 'Settings',
}
return (
<div key={s} className="flex-1 text-center">
<span className={cn(
'text-[10px]',
i <= currentIndex ? 'text-primary font-medium' : 'text-muted-foreground'
)}>
{labels[s] || s}
</span>
</div>
)
})}
</div>
<p className="text-sm text-muted-foreground mt-2">
Step {currentIndex + 1} of {totalVisibleSteps}
</p>
</div>
{/* Step 1: 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 2: 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. You can crop and adjust before saving.
</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 3: Home Country */}
{step === 'country' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5 text-primary" />
Home Country
</CardTitle>
<CardDescription>
Select your home country. This helps us match you with relevant projects.
</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">
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step 4: 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 expertise. This helps us match you with relevant projects. (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 with 10 years experience in coral reef conservation and ocean acidification research..."
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 5: Phone (only if WhatsApp enabled) */}
{step === 'phone' && whatsappEnabled && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Phone className="h-5 w-5 text-primary" />
Contact Information
</CardTitle>
<CardDescription>
Optionally add your phone number for WhatsApp notifications
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="phone">Phone Number (Optional)</Label>
<PhoneInput
id="phone"
value={phoneNumber}
onChange={(value) => setPhoneNumber(value || '')}
defaultCountry="MC"
placeholder="Enter phone number"
/>
<p className="text-xs text-muted-foreground">
Select your country and enter your number for WhatsApp notifications
</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">
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step 6: Tags */}
{step === 'tags' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tags className="h-5 w-5 text-primary" />
Your Expertise
</CardTitle>
<CardDescription>
Select tags that describe your areas of expertise. This helps us match you with relevant projects.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ExpertiseSelect
value={expertiseTags}
onChange={setExpertiseTags}
maxTags={5}
lockedTags={lockedTags}
/>
<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">
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Jury Preferences Step (conditional) */}
{step === 'jury' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Scale className="h-5 w-5 text-primary" />
Jury Preferences
</CardTitle>
<CardDescription>
Customize your assignment preferences for each jury panel you belong to.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{juryMemberships.map((m) => {
const pref = juryPrefs.get(m.juryGroupMemberId) ?? {}
const capValue = pref.cap ?? m.selfServiceCap ?? m.currentCap
const ratioValue = pref.ratio ?? m.selfServiceRatio ?? 0.5
return (
<div key={m.juryGroupMemberId} className="rounded-lg border p-4 space-y-4">
<h4 className="font-medium text-sm">{m.juryGroupName}</h4>
{m.allowCapAdjustment && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Maximum assignments: {capValue}
</Label>
<Slider
value={[capValue]}
onValueChange={([v]) =>
setJuryPrefs((prev) => {
const next = new Map(prev)
next.set(m.juryGroupMemberId, { ...pref, cap: v })
return next
})
}
min={1}
max={m.currentCap}
step={1}
/>
<p className="text-xs text-muted-foreground">
Admin default: {m.currentCap}. You may reduce this to match your availability.
</p>
</div>
)}
{m.allowRatioAdjustment && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Startup vs Business Concept ratio: {Math.round(ratioValue * 100)}% / {Math.round((1 - ratioValue) * 100)}%
</Label>
<Slider
value={[ratioValue * 100]}
onValueChange={([v]) =>
setJuryPrefs((prev) => {
const next = new Map(prev)
next.set(m.juryGroupMemberId, { ...pref, ratio: v / 100 })
return next
})
}
min={0}
max={100}
step={5}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>More Business Concepts</span>
<span>More Startups</span>
</div>
</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={goNext} className="flex-1">
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* 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>
{whatsappEnabled && (
<>
<SelectItem value="WHATSAPP" disabled={!phoneNumber}>
WhatsApp only
</SelectItem>
<SelectItem value="BOTH" disabled={!phoneNumber}>
Both Email and WhatsApp
</SelectItem>
</>
)}
<SelectItem value="NONE">No notifications</SelectItem>
</SelectContent>
</Select>
{whatsappEnabled && !phoneNumber && (
<p className="text-xs text-muted-foreground">
Add a phone number to enable WhatsApp notifications
</p>
)}
</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>
{country && (
<p>
<span className="text-muted-foreground">Country:</span> {country}
</p>
)}
{bio && (
<p>
<span className="text-muted-foreground">Bio:</span>{' '}
{bio.length > 50 ? `${bio.substring(0, 50)}...` : bio}
</p>
)}
{whatsappEnabled && phoneNumber && (
<p>
<span className="text-muted-foreground">Phone:</span>{' '}
{phoneNumber}
</p>
)}
<p>
<span className="text-muted-foreground">Expertise:</span>{' '}
{expertiseTags.length > 0
? expertiseTags.join(', ')
: 'None selected'}
</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 7: 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>
)
}