feat: applicant onboarding, bulk invite, team management enhancements
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m50s
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>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "nationality" TEXT;
|
||||
ALTER TABLE "User" ADD COLUMN "institution" TEXT;
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof inviteSchema>
|
||||
@@ -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() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Nationality</Label>
|
||||
<CountrySelect
|
||||
value={form.watch('nationality') || ''}
|
||||
onChange={(v) => form.setValue('nationality', v)}
|
||||
placeholder="Select nationality"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Country of Residence</Label>
|
||||
<CountrySelect
|
||||
value={form.watch('country') || ''}
|
||||
onChange={(v) => form.setValue('country', v)}
|
||||
placeholder="Select country"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="institution">Institution (optional)</Label>
|
||||
<Input
|
||||
id="institution"
|
||||
placeholder="e.g., Ocean Research Institute"
|
||||
{...form.register('institution')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckboxPrimitive
|
||||
id="sendInvite"
|
||||
checked={form.watch('sendInvite')}
|
||||
onCheckedChange={(checked) => form.setValue('sendInvite', !!checked)}
|
||||
/>
|
||||
<Label htmlFor="sendInvite" className="text-sm font-normal cursor-pointer">
|
||||
Send platform invite email
|
||||
</Label>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
|
||||
<p className="font-medium mb-1">What invited members can do:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
|
||||
@@ -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
|
||||
|
||||
599
src/app/(auth)/onboarding/applicant-wizard.tsx
Normal file
599
src/app/(auth)/onboarding/applicant-wizard.tsx
Normal file
@@ -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<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>
|
||||
)
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
Scale,
|
||||
} from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { ApplicantOnboardingWizard } from './applicant-wizard'
|
||||
|
||||
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'jury' | 'preferences' | 'complete'
|
||||
|
||||
@@ -208,6 +209,8 @@ export default function OnboardingPage() {
|
||||
router.push('/mentor')
|
||||
} else if (role === 'OBSERVER') {
|
||||
router.push('/observer')
|
||||
} else if (role === 'APPLICANT') {
|
||||
router.push('/applicant')
|
||||
} else {
|
||||
router.push('/jury')
|
||||
}
|
||||
@@ -234,6 +237,17 @@ export default function OnboardingPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// Applicant users get a dedicated onboarding wizard
|
||||
if (userData?.role === 'APPLICANT') {
|
||||
return (
|
||||
<ApplicantOnboardingWizard
|
||||
userData={userData as unknown as Parameters<typeof ApplicantOnboardingWizard>[0]['userData']}
|
||||
avatarUrl={avatarUrl}
|
||||
refetchUser={refetchUser}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -42,6 +42,8 @@ export default function SetPasswordPage() {
|
||||
router.push('/jury')
|
||||
} else if (session?.user?.role === 'SUPER_ADMIN' || session?.user?.role === 'PROGRAM_ADMIN') {
|
||||
router.push('/admin')
|
||||
} else if (session?.user?.role === 'APPLICANT') {
|
||||
router.push('/applicant')
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ import { formatRelativeTime } from '@/lib/utils'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||
|
||||
type TabKey = 'all' | 'jury' | 'mentors' | 'observers' | 'admins'
|
||||
type TabKey = 'all' | 'jury' | 'mentors' | 'observers' | 'admins' | 'applicants'
|
||||
|
||||
const TAB_ROLES: Record<TabKey, RoleValue[] | undefined> = {
|
||||
all: undefined,
|
||||
@@ -42,6 +42,7 @@ const TAB_ROLES: Record<TabKey, RoleValue[] | undefined> = {
|
||||
mentors: ['MENTOR'],
|
||||
observers: ['OBSERVER'],
|
||||
admins: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
|
||||
applicants: undefined, // handled separately
|
||||
}
|
||||
|
||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
|
||||
@@ -265,6 +266,7 @@ export function MembersContent() {
|
||||
<TabsTrigger value="mentors">Mentors</TabsTrigger>
|
||||
<TabsTrigger value="observers">Observers</TabsTrigger>
|
||||
<TabsTrigger value="admins">Admins</TabsTrigger>
|
||||
<TabsTrigger value="applicants">Applicants</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Search */}
|
||||
@@ -280,8 +282,11 @@ export function MembersContent() {
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
{/* Applicants tab */}
|
||||
{tab === 'applicants' && <ApplicantsTabContent search={search} searchInput={searchInput} setSearchInput={setSearchInput} />}
|
||||
|
||||
{/* Content (non-applicant tabs) */}
|
||||
{tab !== 'applicants' && isLoading ? (
|
||||
<MembersSkeleton />
|
||||
) : data && data.users.length > 0 ? (
|
||||
<>
|
||||
@@ -535,7 +540,7 @@ export function MembersContent() {
|
||||
onPageChange={(newPage) => updateParams({ page: String(newPage) })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
) : tab !== 'applicants' ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Users className="h-12 w-12 text-muted-foreground/50" />
|
||||
@@ -553,7 +558,7 @@ export function MembersContent() {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Floating bulk invite toolbar */}
|
||||
<AnimatePresence>
|
||||
@@ -602,6 +607,227 @@ export function MembersContent() {
|
||||
)
|
||||
}
|
||||
|
||||
function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search: string; searchInput: string; setSearchInput: (v: string) => void }) {
|
||||
const [page, setPage] = useState(1)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data, isLoading } = trpc.user.getApplicants.useQuery({
|
||||
search: search || undefined,
|
||||
page,
|
||||
perPage: 20,
|
||||
})
|
||||
|
||||
const bulkInvite = trpc.user.bulkInviteApplicants.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const msg = `Sent ${result.sent} invite${result.sent !== 1 ? 's' : ''}`
|
||||
if (result.failed.length > 0) {
|
||||
toast.warning(`${msg}, ${result.failed.length} failed`)
|
||||
} else {
|
||||
toast.success(msg)
|
||||
}
|
||||
setSelectedIds(new Set())
|
||||
utils.user.getApplicants.invalidate()
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const toggleUser = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const users = data?.users ?? []
|
||||
const allSelected = users.length > 0 && users.every((u) => selectedIds.has(u.id))
|
||||
const someSelected = users.some((u) => selectedIds.has(u.id)) && !allSelected
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
if (allSelected) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
users.forEach((u) => next.delete(u.id))
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
users.forEach((u) => next.add(u.id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [allSelected, users])
|
||||
|
||||
if (isLoading) return <MembersSkeleton />
|
||||
|
||||
if (!data || data.users.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Users className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No applicants found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{search ? 'Try adjusting your search' : 'Applicant users will appear here after CSV import or project submission'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<Card className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Applicant</TableHead>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last Login</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(user.id)}
|
||||
onCheckedChange={() => toggleUser(user.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{user.name || 'Unnamed'}</p>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.projectName ? (
|
||||
<span className="text-sm">{user.projectName}</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||
{statusLabels[user.status] || user.status}
|
||||
</Badge>
|
||||
{user.status === 'NONE' && (
|
||||
<InlineSendInvite userId={user.id} userEmail={user.email} />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.lastLoginAt ? (
|
||||
<span title={new Date(user.lastLoginAt).toLocaleString()}>
|
||||
{formatRelativeTime(user.lastLoginAt)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Never</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="space-y-4 md:hidden">
|
||||
{data.users.map((user) => (
|
||||
<Card key={user.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(user.id)}
|
||||
onCheckedChange={() => toggleUser(user.id)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium">{user.name || 'Unnamed'}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
|
||||
{user.projectName && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{user.projectName}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||
{statusLabels[user.status] || user.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.totalPages > 1 && (
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={data.totalPages}
|
||||
total={data.total}
|
||||
perPage={data.perPage}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating bulk invite bar */}
|
||||
<AnimatePresence>
|
||||
{selectedIds.size > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50"
|
||||
>
|
||||
<Card className="shadow-lg border-2">
|
||||
<CardContent className="flex items-center gap-3 px-4 py-3">
|
||||
<span className="text-sm font-medium whitespace-nowrap">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => bulkInvite.mutate({ userIds: Array.from(selectedIds) })}
|
||||
disabled={bulkInvite.isPending}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{bulkInvite.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
Send Invites
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
disabled={bulkInvite.isPending}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MembersSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
|
||||
279
src/components/shared/project-logo-upload.tsx
Normal file
279
src/components/shared/project-logo-upload.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import Cropper from 'react-easy-crop'
|
||||
import type { Area } from 'react-easy-crop'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Upload, Loader2, ZoomIn, ImageIcon } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type ProjectLogoUploadProps = {
|
||||
projectId: string
|
||||
currentLogoUrl?: string | null
|
||||
onUploadComplete?: () => void
|
||||
}
|
||||
|
||||
const MAX_SIZE_MB = 5
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
|
||||
async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<Blob> {
|
||||
const image = new Image()
|
||||
image.crossOrigin = 'anonymous'
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve()
|
||||
image.onerror = reject
|
||||
image.src = imageSrc
|
||||
})
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = pixelCrop.width
|
||||
canvas.height = pixelCrop.height
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height
|
||||
)
|
||||
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) resolve(blob)
|
||||
else reject(new Error('Canvas toBlob failed'))
|
||||
},
|
||||
'image/png',
|
||||
0.9
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function ProjectLogoUpload({
|
||||
projectId,
|
||||
currentLogoUrl,
|
||||
onUploadComplete,
|
||||
}: ProjectLogoUploadProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null)
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const getUploadUrl = trpc.applicant.getProjectLogoUploadUrl.useMutation()
|
||||
const confirmUpload = trpc.applicant.confirmProjectLogo.useMutation()
|
||||
|
||||
const onCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedPixels)
|
||||
}, [])
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
toast.error('Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
|
||||
toast.error(`File too large. Maximum size is ${MAX_SIZE_MB}MB.`)
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
setImageSrc(ev.target?.result as string)
|
||||
setCrop({ x: 0, y: 0 })
|
||||
setZoom(1)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}, [])
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!imageSrc || !croppedAreaPixels) return
|
||||
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const croppedBlob = await getCroppedImg(imageSrc, croppedAreaPixels)
|
||||
|
||||
const { uploadUrl, key, providerType } = await getUploadUrl.mutateAsync({
|
||||
projectId,
|
||||
fileName: 'logo.png',
|
||||
contentType: 'image/png',
|
||||
})
|
||||
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: croppedBlob,
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
})
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Failed to upload file')
|
||||
}
|
||||
|
||||
await confirmUpload.mutateAsync({ projectId, key, providerType })
|
||||
|
||||
toast.success('Project logo updated')
|
||||
setOpen(false)
|
||||
resetState()
|
||||
onUploadComplete?.()
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toast.error('Failed to upload logo. Please try again.')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
setImageSrc(null)
|
||||
setCrop({ x: 0, y: 0 })
|
||||
setZoom(1)
|
||||
setCroppedAreaPixels(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
resetState()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="relative mx-auto flex h-24 w-24 items-center justify-center rounded-xl border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 transition-colors cursor-pointer overflow-hidden bg-muted"
|
||||
>
|
||||
{currentLogoUrl ? (
|
||||
<img src={currentLogoUrl} alt="Project logo" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
|
||||
)}
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Project Logo</DialogTitle>
|
||||
<DialogDescription>
|
||||
{imageSrc
|
||||
? 'Drag to reposition and use the slider to zoom.'
|
||||
: 'Upload a logo for your project. Allowed formats: JPEG, PNG, GIF, WebP.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{imageSrc ? (
|
||||
<>
|
||||
<div className="relative w-full h-64 bg-muted rounded-lg overflow-hidden">
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onCropComplete={onCropComplete}
|
||||
onZoomChange={setZoom}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<ZoomIn className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<Slider
|
||||
value={[zoom]}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
onValueChange={([val]) => setZoom(val)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
resetState()
|
||||
fileInputRef.current?.click()
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Choose a different image
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{currentLogoUrl && (
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={currentLogoUrl}
|
||||
alt="Current logo"
|
||||
className="h-24 w-24 rounded-xl object-cover border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logo">Select image</Label>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
id="logo"
|
||||
type="file"
|
||||
accept={ALLOWED_TYPES.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-row">
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button variant="outline" onClick={handleCancel} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
{imageSrc && (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!croppedAreaPixels || isUploading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Upload
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, publicProcedure, protectedProcedure } from '../trpc'
|
||||
import { getPresignedUrl, generateObjectKey } from '@/lib/minio'
|
||||
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
|
||||
import { getImageUploadUrl, confirmImageUpload, getImageUrl, type ImageUploadConfig } from '@/server/utils/image-upload'
|
||||
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { createNotification } from '../services/in-app-notification'
|
||||
@@ -757,6 +759,10 @@ export const applicantRouter = router({
|
||||
name: z.string().min(1),
|
||||
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),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -813,9 +819,25 @@ export const applicantRouter = router({
|
||||
email: normalizedEmail,
|
||||
name: input.name,
|
||||
role: 'APPLICANT',
|
||||
roles: ['APPLICANT'],
|
||||
status: 'NONE',
|
||||
nationality: input.nationality,
|
||||
country: input.country,
|
||||
institution: input.institution,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Update existing user with new profile fields if provided
|
||||
const profileUpdates: Record<string, string> = {}
|
||||
if (input.nationality && !user.nationality) profileUpdates.nationality = input.nationality
|
||||
if (input.country && !user.country) profileUpdates.country = input.country
|
||||
if (input.institution && !user.institution) profileUpdates.institution = input.institution
|
||||
if (Object.keys(profileUpdates).length > 0) {
|
||||
user = await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: profileUpdates,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (user.status === 'SUSPENDED') {
|
||||
@@ -829,7 +851,10 @@ export const applicantRouter = router({
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
||||
const requiresAccountSetup = user.status !== 'ACTIVE'
|
||||
|
||||
try {
|
||||
// If sendInvite is false, skip email entirely and leave user as NONE
|
||||
if (!input.sendInvite) {
|
||||
// No email, no status change — just create team membership below
|
||||
} else try {
|
||||
if (requiresAccountSetup) {
|
||||
const token = generateInviteToken()
|
||||
await ctx.prisma.user.update({
|
||||
@@ -1607,7 +1632,8 @@ export const applicantRouter = router({
|
||||
}>
|
||||
}> = []
|
||||
|
||||
for (const round of evalRounds) {
|
||||
for (let i = 0; i < evalRounds.length; i++) {
|
||||
const round = evalRounds[i]
|
||||
const parsed = EvaluationConfigSchema.safeParse(round.configJson)
|
||||
if (!parsed.success || !parsed.data.applicantVisibility.enabled) continue
|
||||
|
||||
@@ -1633,9 +1659,12 @@ export const applicantRouter = router({
|
||||
orderBy: { submittedAt: 'asc' },
|
||||
})
|
||||
|
||||
// Mask round names: "Evaluation Round 1", "Evaluation Round 2", etc.
|
||||
const maskedName = `Evaluation Round ${i + 1}`
|
||||
|
||||
results.push({
|
||||
roundId: round.id,
|
||||
roundName: round.name,
|
||||
roundName: maskedName,
|
||||
evaluationCount: evaluations.length,
|
||||
evaluations: evaluations.map((ev) => ({
|
||||
id: ev.id,
|
||||
@@ -1752,4 +1781,155 @@ export const applicantRouter = router({
|
||||
|
||||
return results
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get onboarding context for applicant wizard — project info, institution, logo status.
|
||||
*/
|
||||
getOnboardingContext: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
return null
|
||||
}
|
||||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
institution: true,
|
||||
logoKey: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) return null
|
||||
|
||||
return {
|
||||
projectId: project.id,
|
||||
projectTitle: project.title,
|
||||
institution: project.institution,
|
||||
hasLogo: !!project.logoKey,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a pre-signed URL for uploading a project logo (applicant access).
|
||||
*/
|
||||
getProjectLogoUploadUrl: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
fileName: z.string(),
|
||||
contentType: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify team membership
|
||||
const isMember = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!isMember) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
|
||||
}
|
||||
|
||||
return getImageUploadUrl(
|
||||
input.projectId,
|
||||
input.fileName,
|
||||
input.contentType,
|
||||
generateLogoKey
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Confirm project logo upload (applicant access).
|
||||
*/
|
||||
confirmProjectLogo: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
key: z.string(),
|
||||
providerType: z.enum(['s3', 'local']),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify team membership
|
||||
const isMember = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!isMember) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
|
||||
}
|
||||
|
||||
const logoConfig: ImageUploadConfig<{ logoKey: string | null; logoProvider: string | null }> = {
|
||||
label: 'logo',
|
||||
generateKey: generateLogoKey,
|
||||
findCurrent: (prisma, entityId) =>
|
||||
prisma.project.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { logoKey: true, logoProvider: true },
|
||||
}),
|
||||
getImageKey: (record) => record.logoKey,
|
||||
getProviderType: (record) =>
|
||||
(record.logoProvider as StorageProviderType) || 's3',
|
||||
setImage: (prisma, entityId, key, providerType) =>
|
||||
prisma.project.update({
|
||||
where: { id: entityId },
|
||||
data: { logoKey: key, logoProvider: providerType },
|
||||
}),
|
||||
clearImage: (prisma, entityId) =>
|
||||
prisma.project.update({
|
||||
where: { id: entityId },
|
||||
data: { logoKey: null, logoProvider: null },
|
||||
}),
|
||||
auditEntityType: 'Project',
|
||||
auditFieldName: 'logoKey',
|
||||
}
|
||||
|
||||
await confirmImageUpload(ctx.prisma, logoConfig, input.projectId, input.key, input.providerType, {
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get project logo URL (applicant access).
|
||||
*/
|
||||
getProjectLogoUrl: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const logoConfig = {
|
||||
findCurrent: (prisma: typeof ctx.prisma, entityId: string) =>
|
||||
prisma.project.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { logoKey: true, logoProvider: true },
|
||||
}),
|
||||
getImageKey: (record: { logoKey: string | null }) => record.logoKey,
|
||||
getProviderType: (record: { logoProvider: string | null }) =>
|
||||
(record.logoProvider as StorageProviderType) || 's3' as StorageProviderType,
|
||||
}
|
||||
|
||||
return getImageUrl(ctx.prisma, logoConfig, input.projectId)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -49,6 +49,8 @@ export const userRouter = router({
|
||||
metadataJson: true,
|
||||
phoneNumber: true,
|
||||
country: true,
|
||||
nationality: true,
|
||||
institution: true,
|
||||
bio: true,
|
||||
notificationPreference: true,
|
||||
profileImageKey: true,
|
||||
@@ -109,6 +111,9 @@ export const userRouter = router({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
bio: z.string().max(1000).optional(),
|
||||
phoneNumber: z.string().max(20).optional().nullable(),
|
||||
nationality: z.string().max(100).optional().nullable(),
|
||||
institution: z.string().max(255).optional().nullable(),
|
||||
country: z.string().max(100).optional(),
|
||||
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
|
||||
expertiseTags: z.array(z.string()).max(15).optional(),
|
||||
digestFrequency: z.enum(['none', 'daily', 'weekly']).optional(),
|
||||
@@ -1147,6 +1152,8 @@ export const userRouter = router({
|
||||
name: z.string().min(1).max(255),
|
||||
phoneNumber: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
nationality: z.string().optional(),
|
||||
institution: z.string().optional(),
|
||||
bio: z.string().max(500).optional(),
|
||||
expertiseTags: z.array(z.string()).optional(),
|
||||
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
|
||||
@@ -1181,6 +1188,8 @@ export const userRouter = router({
|
||||
name: input.name,
|
||||
phoneNumber: input.phoneNumber,
|
||||
country: input.country,
|
||||
nationality: input.nationality,
|
||||
institution: input.institution,
|
||||
bio: input.bio,
|
||||
expertiseTags: mergedTags,
|
||||
notificationPreference: input.notificationPreference || 'EMAIL',
|
||||
@@ -1552,4 +1561,143 @@ export const userRouter = router({
|
||||
data: { roles: input.roles, role: primaryRole },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* List applicant users with project info for admin bulk-invite page.
|
||||
*/
|
||||
getApplicants: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
search: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||
page: z.number().int().positive().default(1),
|
||||
perPage: z.number().int().positive().max(100).default(20),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Prisma.UserWhereInput = {
|
||||
role: 'APPLICANT',
|
||||
...(input.status && { status: input.status }),
|
||||
...(input.search && {
|
||||
OR: [
|
||||
{ name: { contains: input.search, mode: 'insensitive' as const } },
|
||||
{ email: { contains: input.search, mode: 'insensitive' as const } },
|
||||
{ teamMemberships: { some: { project: { title: { contains: input.search, mode: 'insensitive' as const } } } } },
|
||||
],
|
||||
}),
|
||||
...(input.roundId && {
|
||||
teamMemberships: { some: { project: { projectRoundStates: { some: { roundId: input.roundId } } } } },
|
||||
}),
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
ctx.prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
status: true,
|
||||
nationality: true,
|
||||
institution: true,
|
||||
lastLoginAt: true,
|
||||
onboardingCompletedAt: true,
|
||||
teamMemberships: {
|
||||
take: 1,
|
||||
select: {
|
||||
role: true,
|
||||
project: { select: { id: true, title: true } },
|
||||
},
|
||||
},
|
||||
submittedProjects: {
|
||||
take: 1,
|
||||
select: { id: true, title: true },
|
||||
},
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
skip: (input.page - 1) * input.perPage,
|
||||
take: input.perPage,
|
||||
}),
|
||||
ctx.prisma.user.count({ where }),
|
||||
])
|
||||
|
||||
return {
|
||||
users: users.map((u) => {
|
||||
const project = u.submittedProjects[0] || u.teamMemberships[0]?.project || null
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
status: u.status,
|
||||
nationality: u.nationality,
|
||||
institution: u.institution,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
onboardingCompleted: !!u.onboardingCompletedAt,
|
||||
projectName: project?.title ?? null,
|
||||
projectId: project?.id ?? null,
|
||||
}
|
||||
}),
|
||||
total,
|
||||
totalPages: Math.ceil(total / input.perPage),
|
||||
perPage: input.perPage,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Bulk invite applicant users — generates tokens, sets INVITED, sends emails.
|
||||
*/
|
||||
bulkInviteApplicants: adminProcedure
|
||||
.input(z.object({ userIds: z.array(z.string()).min(1).max(500) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const users = await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: input.userIds },
|
||||
role: 'APPLICANT',
|
||||
status: { in: ['NONE', 'INVITED'] },
|
||||
},
|
||||
select: { id: true, email: true, name: true, status: true },
|
||||
})
|
||||
|
||||
const expiryMs = await getInviteExpiryMs(ctx.prisma)
|
||||
let sent = 0
|
||||
let skipped = 0
|
||||
const failed: string[] = []
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
const token = generateInviteToken()
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
status: 'INVITED',
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
||||
},
|
||||
})
|
||||
|
||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
||||
await sendInvitationEmail(user.email, user.name || 'Applicant', inviteUrl, 'APPLICANT')
|
||||
sent++
|
||||
} catch (error) {
|
||||
failed.push(user.email)
|
||||
}
|
||||
}
|
||||
|
||||
skipped = input.userIds.length - users.length
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_INVITE_APPLICANTS',
|
||||
entityType: 'User',
|
||||
entityId: 'bulk',
|
||||
detailsJson: { sent, skipped, failed: failed.length, total: input.userIds.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { sent, skipped, failed }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user