From 49e706f2cf043ec211bcbb187c702e343bbd6977 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 2 Mar 2026 10:11:11 +0100 Subject: [PATCH] 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 --- .../migration.sql | 3 + prisma/schema.prisma | 2 + src/app/(applicant)/applicant/team/page.tsx | 46 ++ src/app/(auth)/layout.tsx | 2 + .../(auth)/onboarding/applicant-wizard.tsx | 599 ++++++++++++++++++ src/app/(auth)/onboarding/page.tsx | 14 + src/app/(auth)/set-password/page.tsx | 2 + src/components/admin/members-content.tsx | 236 ++++++- src/components/shared/project-logo-upload.tsx | 279 ++++++++ src/server/routers/applicant.ts | 186 +++++- src/server/routers/user.ts | 148 +++++ 11 files changed, 1509 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20260302000000_add_user_nationality_institution/migration.sql create mode 100644 src/app/(auth)/onboarding/applicant-wizard.tsx create mode 100644 src/components/shared/project-logo-upload.tsx diff --git a/prisma/migrations/20260302000000_add_user_nationality_institution/migration.sql b/prisma/migrations/20260302000000_add_user_nationality_institution/migration.sql new file mode 100644 index 0000000..3f77816 --- /dev/null +++ b/prisma/migrations/20260302000000_add_user_nationality_institution/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "nationality" TEXT; +ALTER TABLE "User" ADD COLUMN "institution" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c584efe..72ceb68 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -308,6 +308,8 @@ model User { expertiseTags String[] @default([]) maxAssignments Int? // Per-round limit country String? // User's home country (for mentor matching) + nationality String? // User's nationality (for applicant profiles) + institution String? // User's institution/organization metadataJson Json? @db.JsonB // Profile diff --git a/src/app/(applicant)/applicant/team/page.tsx b/src/app/(applicant)/applicant/team/page.tsx index a44ca7e..d07cd0b 100644 --- a/src/app/(applicant)/applicant/team/page.tsx +++ b/src/app/(applicant)/applicant/team/page.tsx @@ -46,6 +46,8 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog' +import { CountrySelect } from '@/components/ui/country-select' +import { Checkbox as CheckboxPrimitive } from '@/components/ui/checkbox' import { Users, UserPlus, @@ -64,6 +66,10 @@ const inviteSchema = z.object({ email: z.string().email('Invalid email address'), role: z.enum(['MEMBER', 'ADVISOR']), title: z.string().optional(), + nationality: z.string().optional(), + country: z.string().optional(), + institution: z.string().optional(), + sendInvite: z.boolean().default(true), }) type InviteFormData = z.infer @@ -129,6 +135,10 @@ export default function ApplicantTeamPage() { email: '', role: 'MEMBER', title: '', + nationality: '', + country: '', + institution: '', + sendInvite: true, }, }) @@ -280,6 +290,42 @@ export default function ApplicantTeamPage() { /> +
+
+ + form.setValue('nationality', v)} + placeholder="Select nationality" + /> +
+
+ + form.setValue('country', v)} + placeholder="Select country" + /> +
+
+
+ + +
+
+ form.setValue('sendInvite', !!checked)} + /> + +

What invited members can do:

    diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 5f2c8b3..18e65a2 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -35,6 +35,8 @@ export default async function AuthLayout({ redirect('/observer') } else if (role === 'MENTOR') { redirect('/mentor') + } else if (role === 'APPLICANT') { + redirect('/applicant') } } // If user doesn't exist in DB, fall through and show auth page diff --git a/src/app/(auth)/onboarding/applicant-wizard.tsx b/src/app/(auth)/onboarding/applicant-wizard.tsx new file mode 100644 index 0000000..d426d22 --- /dev/null +++ b/src/app/(auth)/onboarding/applicant-wizard.tsx @@ -0,0 +1,599 @@ +'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('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 = { + name: 'Name', + photo: 'Photo', + nationality: 'Nationality', + country: 'Residence', + institution: 'Institution', + bio: 'About', + logo: 'Logo', + preferences: 'Settings', + complete: 'Done', + } + + return ( +
    + + +
    + + {/* Progress indicator */} + {step !== 'complete' && ( +
    +
    + {steps.slice(0, -1).map((s, i) => ( +
    +
    +
    + ))} +
    +
    + {steps.slice(0, -1).map((s, i) => ( +
    + + {stepLabels[s]} + +
    + ))} +
    +

    + Step {currentIndex + 1} of {totalVisibleSteps} +

    +
    + )} + + {/* Step: Name */} + {step === 'name' && ( + <> + + + + Welcome to MOPC + + + Let's get your profile set up. What should we call you? + + + +
    + + setName(e.target.value)} + placeholder="Enter your full name" + autoFocus + /> +
    + +
    + + )} + + {/* Step: Profile Photo */} + {step === 'photo' && ( + <> + + + + Profile Photo + + + Add a profile photo so others can recognize you. This step is optional. + + + +
    + refetchUser()} + /> +
    +

    + Click the avatar to upload a new photo. +

    +
    + + +
    +
    + + )} + + {/* Step: Nationality */} + {step === 'nationality' && ( + <> + + + + Nationality + + + Select your nationality. + + + +
    + + +
    +
    + + +
    +
    + + )} + + {/* Step: Country of Residence */} + {step === 'country' && ( + <> + + + + Country of Residence + + + Where are you currently based? + + + +
    + + +
    +
    + + +
    +
    + + )} + + {/* Step: Institution */} + {step === 'institution' && ( + <> + + + + Institution + + + Your organization or institution name. + + + +
    + + setInstitution(e.target.value)} + placeholder="e.g., Ocean Research Institute" + /> +
    +
    + + +
    +
    + + )} + + {/* Step: Bio */} + {step === 'bio' && ( + <> + + + + About You + + + Tell us a bit about yourself and your work. (Optional) + + + +
    + +