Add unified expertise tag system and round entry notifications
- ExpertiseSelect now fetches tags from database with category grouping - Tags set by admin during invitation are locked and cannot be removed - Onboarding merges user-selected tags with admin-preset tags - MENTOR role now goes through onboarding flow - Added migration for Round.entryNotificationType column - Added seed script with ~90 comprehensive expertise tags Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -39,15 +39,44 @@ type Step = 'name' | 'phone' | 'tags' | 'preferences' | 'complete'
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<Step>('name')
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('')
|
||||
const [phoneNumber, setPhoneNumber] = useState('')
|
||||
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
||||
const [lockedTags, setLockedTags] = useState<string[]>([])
|
||||
const [notificationPreference, setNotificationPreference] = useState<
|
||||
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
|
||||
>('EMAIL')
|
||||
|
||||
// Fetch current user data to get admin-preset tags
|
||||
const { data: userData, isLoading: userLoading } = trpc.user.me.useQuery()
|
||||
|
||||
// Initialize form with user data
|
||||
useEffect(() => {
|
||||
if (userData && !initialized) {
|
||||
// Pre-fill name if available
|
||||
if (userData.name) {
|
||||
setName(userData.name)
|
||||
}
|
||||
// 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 feature flags
|
||||
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery()
|
||||
const whatsappEnabled = featureFlags?.whatsappEnabled ?? false
|
||||
@@ -95,15 +124,36 @@ export default function OnboardingPage() {
|
||||
setStep('complete')
|
||||
toast.success('Welcome to MOPC!')
|
||||
|
||||
// Redirect after a short delay
|
||||
// Redirect after a short delay based on user role
|
||||
setTimeout(() => {
|
||||
router.push('/jury')
|
||||
const role = userData?.role
|
||||
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 fetching user data
|
||||
if (userLoading || !initialized) {
|
||||
return (
|
||||
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
|
||||
<Card className="w-full max-w-lg shadow-2xl">
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
|
||||
<Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto shadow-2xl">
|
||||
@@ -219,6 +269,7 @@ export default function OnboardingPage() {
|
||||
value={expertiseTags}
|
||||
onChange={setExpertiseTags}
|
||||
maxTags={5}
|
||||
lockedTags={lockedTags}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check, X } from 'lucide-react'
|
||||
|
||||
// Predefined expertise areas for ocean conservation
|
||||
const EXPERTISE_OPTIONS = [
|
||||
{ id: 'marine-biology', name: 'Marine Biology', color: '#0ea5e9' },
|
||||
{ id: 'ocean-conservation', name: 'Ocean Conservation', color: '#06b6d4' },
|
||||
{ id: 'climate-science', name: 'Climate Science', color: '#14b8a6' },
|
||||
{ id: 'sustainable-fishing', name: 'Sustainable Fishing', color: '#22c55e' },
|
||||
{ id: 'plastic-pollution', name: 'Plastic Pollution', color: '#84cc16' },
|
||||
{ id: 'coral-reef', name: 'Coral Reef Restoration', color: '#f97316' },
|
||||
{ id: 'blue-economy', name: 'Blue Economy', color: '#3b82f6' },
|
||||
{ id: 'marine-technology', name: 'Marine Technology', color: '#8b5cf6' },
|
||||
{ id: 'environmental-policy', name: 'Environmental Policy', color: '#a855f7' },
|
||||
{ id: 'oceanography', name: 'Oceanography', color: '#0284c7' },
|
||||
{ id: 'renewable-energy', name: 'Renewable Energy', color: '#16a34a' },
|
||||
{ id: 'waste-management', name: 'Waste Management', color: '#65a30d' },
|
||||
{ id: 'biodiversity', name: 'Biodiversity', color: '#059669' },
|
||||
{ id: 'shipping-maritime', name: 'Shipping & Maritime', color: '#6366f1' },
|
||||
{ id: 'education-outreach', name: 'Education & Outreach', color: '#ec4899' },
|
||||
{ id: 'entrepreneurship', name: 'Entrepreneurship', color: '#f43f5e' },
|
||||
{ id: 'investment-finance', name: 'Investment & Finance', color: '#eab308' },
|
||||
{ id: 'research-academia', name: 'Research & Academia', color: '#7c3aed' },
|
||||
]
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Check, X, Lock, Search, Loader2, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface ExpertiseSelectProps {
|
||||
value: string[]
|
||||
@@ -34,6 +14,7 @@ interface ExpertiseSelectProps {
|
||||
maxTags?: number
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
lockedTags?: string[] // Tags set by admin that cannot be removed
|
||||
}
|
||||
|
||||
export function ExpertiseSelect({
|
||||
@@ -42,46 +23,141 @@ export function ExpertiseSelect({
|
||||
maxTags = 10,
|
||||
disabled = false,
|
||||
className,
|
||||
lockedTags = [],
|
||||
}: ExpertiseSelectProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
|
||||
// Fetch tags from database
|
||||
const { data, isLoading } = trpc.tag.list.useQuery({ isActive: true })
|
||||
const tags = data?.tags || []
|
||||
|
||||
// Group tags by category
|
||||
const tagsByCategory = useMemo(() => {
|
||||
const grouped: Record<string, typeof tags> = {}
|
||||
for (const tag of tags) {
|
||||
const category = tag.category || 'Other'
|
||||
if (!grouped[category]) {
|
||||
grouped[category] = []
|
||||
}
|
||||
grouped[category].push(tag)
|
||||
}
|
||||
return grouped
|
||||
}, [tags])
|
||||
|
||||
// Filter tags by search query
|
||||
const filteredTagsByCategory = useMemo(() => {
|
||||
if (!searchQuery.trim()) return tagsByCategory
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
const filtered: Record<string, typeof tags> = {}
|
||||
|
||||
for (const [category, categoryTags] of Object.entries(tagsByCategory)) {
|
||||
const matchingTags = categoryTags.filter(
|
||||
(tag) =>
|
||||
tag.name.toLowerCase().includes(query) ||
|
||||
tag.description?.toLowerCase().includes(query) ||
|
||||
category.toLowerCase().includes(query)
|
||||
)
|
||||
if (matchingTags.length > 0) {
|
||||
filtered[category] = matchingTags
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [tagsByCategory, searchQuery])
|
||||
|
||||
// Check if a tag is locked
|
||||
const isTagLocked = (tagName: string) => lockedTags.includes(tagName)
|
||||
|
||||
const handleToggle = (name: string) => {
|
||||
if (disabled) return
|
||||
if (disabled || isTagLocked(name)) return
|
||||
|
||||
if (value.includes(name)) {
|
||||
onChange(value.filter((t) => t !== name))
|
||||
} else {
|
||||
if (maxTags && value.length >= maxTags) return
|
||||
// Don't count locked tags against the max
|
||||
const selectableTags = value.filter((t) => !isTagLocked(t))
|
||||
if (maxTags && selectableTags.length >= maxTags) return
|
||||
onChange([...value, name])
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = (name: string) => {
|
||||
if (disabled) return
|
||||
if (disabled || isTagLocked(name)) return
|
||||
onChange(value.filter((t) => t !== name))
|
||||
}
|
||||
|
||||
const getOption = (name: string) =>
|
||||
EXPERTISE_OPTIONS.find((o) => o.name === name)
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(category)) {
|
||||
next.delete(category)
|
||||
} else {
|
||||
next.add(category)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const getTag = (name: string) => tags.find((t) => t.name === name)
|
||||
|
||||
// Count user-selected tags (not including locked)
|
||||
const userSelectedCount = value.filter((t) => !isTagLocked(t)).length
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center py-8', className)}>
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading expertise areas...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tags.length === 0) {
|
||||
return (
|
||||
<div className={cn('text-center py-8 text-muted-foreground', className)}>
|
||||
No expertise areas available. Please contact an administrator.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search expertise areas..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selected tags at the top */}
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{value.map((name) => {
|
||||
const option = getOption(name)
|
||||
const tag = getTag(name)
|
||||
const isLocked = isTagLocked(name)
|
||||
return (
|
||||
<Badge
|
||||
key={name}
|
||||
variant="secondary"
|
||||
className="gap-1.5 py-1 px-2 text-sm"
|
||||
className={cn(
|
||||
'gap-1.5 py-1 px-2 text-sm',
|
||||
isLocked && 'opacity-75'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: option?.color ? `${option.color}15` : undefined,
|
||||
borderColor: option?.color || undefined,
|
||||
color: option?.color || undefined,
|
||||
backgroundColor: tag?.color ? `${tag.color}15` : undefined,
|
||||
borderColor: tag?.color || undefined,
|
||||
color: tag?.color || undefined,
|
||||
}}
|
||||
>
|
||||
{isLocked && <Lock className="h-3 w-3 mr-0.5" />}
|
||||
{name}
|
||||
{!disabled && (
|
||||
{!disabled && !isLocked && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(name)}
|
||||
@@ -97,52 +173,115 @@ export function ExpertiseSelect({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid of options */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{EXPERTISE_OPTIONS.map((option) => {
|
||||
const isSelected = value.includes(option.name)
|
||||
const isDisabled = disabled || (!isSelected && value.length >= maxTags)
|
||||
{/* Locked tags notice */}
|
||||
{lockedTags.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Lock className="h-3 w-3" />
|
||||
Tags with a lock icon were pre-selected by your administrator
|
||||
</p>
|
||||
)}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={option.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isDisabled}
|
||||
onClick={() => handleToggle(option.name)}
|
||||
className={cn(
|
||||
'justify-start h-auto py-2 px-3 text-left font-normal transition-all',
|
||||
isSelected && 'ring-2 ring-offset-1',
|
||||
isDisabled && !isSelected && 'opacity-50'
|
||||
)}
|
||||
style={{
|
||||
borderColor: isSelected ? option.color : undefined,
|
||||
ringColor: option.color,
|
||||
backgroundColor: isSelected ? `${option.color}10` : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-4 w-4 rounded border-2 mr-2 flex items-center justify-center transition-colors',
|
||||
isSelected ? 'border-current bg-current' : 'border-muted-foreground/30'
|
||||
{/* Categories with expandable tag lists */}
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||
{Object.entries(filteredTagsByCategory)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([category, categoryTags]) => {
|
||||
const isExpanded = expandedCategories.has(category) || searchQuery.trim() !== ''
|
||||
const selectedInCategory = categoryTags.filter((t) => value.includes(t.name)).length
|
||||
|
||||
return (
|
||||
<div key={category} className="border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCategory(category)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-muted/50 hover:bg-muted transition-colors text-sm font-medium"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{category}
|
||||
</span>
|
||||
{selectedInCategory > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{selectedInCategory} selected
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1 p-2">
|
||||
{categoryTags.map((tag) => {
|
||||
const isSelected = value.includes(tag.name)
|
||||
const isLocked = isTagLocked(tag.name)
|
||||
const isDisabledOption =
|
||||
disabled ||
|
||||
(isLocked && isSelected) ||
|
||||
(!isSelected && !isLocked && userSelectedCount >= maxTags)
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isDisabledOption && !isSelected}
|
||||
onClick={() => handleToggle(tag.name)}
|
||||
className={cn(
|
||||
'justify-start h-auto py-1.5 px-2 text-left font-normal transition-all',
|
||||
isLocked && 'cursor-not-allowed',
|
||||
isDisabledOption && !isSelected && 'opacity-50'
|
||||
)}
|
||||
style={{
|
||||
borderColor: isSelected ? tag.color || undefined : undefined,
|
||||
backgroundColor: isSelected ? `${tag.color}10` : undefined,
|
||||
// Use box-shadow for ring effect with dynamic color
|
||||
boxShadow: isSelected && tag.color ? `0 0 0 2px ${tag.color}30` : undefined,
|
||||
}}
|
||||
title={tag.description || tag.name}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 rounded border-2 mr-2 flex items-center justify-center transition-colors shrink-0',
|
||||
isSelected
|
||||
? 'border-current bg-current'
|
||||
: 'border-muted-foreground/30'
|
||||
)}
|
||||
style={{
|
||||
borderColor: isSelected ? tag.color || undefined : undefined,
|
||||
backgroundColor: isSelected ? tag.color || undefined : undefined,
|
||||
}}
|
||||
>
|
||||
{isSelected &&
|
||||
(isLocked ? (
|
||||
<Lock className="h-2 w-2 text-white" />
|
||||
) : (
|
||||
<Check className="h-2 w-2 text-white" />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs truncate">{tag.name}</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
style={{
|
||||
borderColor: isSelected ? option.color : undefined,
|
||||
backgroundColor: isSelected ? option.color : undefined,
|
||||
}}
|
||||
>
|
||||
{isSelected && <Check className="h-3 w-3 text-white" />}
|
||||
</div>
|
||||
<span className="text-sm">{option.name}</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{Object.keys(filteredTagsByCategory).length === 0 && searchQuery && (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
No expertise areas match your search.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Counter */}
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{value.length} of {maxTags} selected
|
||||
{userSelectedCount} of {maxTags} selected
|
||||
{lockedTags.length > 0 && ` (+ ${lockedTags.length} pre-selected)`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -734,12 +734,23 @@ export const userRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Get existing user to preserve admin-set tags
|
||||
const existingUser = await ctx.prisma.user.findUniqueOrThrow({
|
||||
where: { id: ctx.user.id },
|
||||
select: { expertiseTags: true },
|
||||
})
|
||||
|
||||
// Merge admin-set tags with user-selected tags (preserving order: admin first, then user)
|
||||
const adminTags = existingUser.expertiseTags || []
|
||||
const userTags = input.expertiseTags || []
|
||||
const mergedTags = [...new Set([...adminTags, ...userTags])]
|
||||
|
||||
const user = await ctx.prisma.user.update({
|
||||
where: { id: ctx.user.id },
|
||||
data: {
|
||||
name: input.name,
|
||||
phoneNumber: input.phoneNumber,
|
||||
expertiseTags: input.expertiseTags || [],
|
||||
expertiseTags: mergedTags,
|
||||
notificationPreference: input.notificationPreference || 'EMAIL',
|
||||
onboardingCompletedAt: new Date(),
|
||||
status: 'ACTIVE', // Activate user after onboarding
|
||||
@@ -771,8 +782,9 @@ export const userRouter = router({
|
||||
select: { onboardingCompletedAt: true, role: true },
|
||||
})
|
||||
|
||||
// Only jury members need onboarding
|
||||
if (user.role !== 'JURY_MEMBER') {
|
||||
// Jury members and mentors need onboarding
|
||||
const rolesRequiringOnboarding = ['JURY_MEMBER', 'MENTOR']
|
||||
if (!rolesRequiringOnboarding.includes(user.role)) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user