Add unified expertise tag system and round entry notifications
Build and Push Docker Image / build (push) Successful in 9m14s Details

- 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:
Matt 2026-02-04 01:15:21 +01:00
parent 41a36f72b3
commit 8cdf6c9e5e
5 changed files with 475 additions and 80 deletions

View File

@ -0,0 +1,4 @@
-- AddRoundEntryNotification
-- Adds the entryNotificationType column to Round table for configurable notifications
ALTER TABLE "Round" ADD COLUMN "entryNotificationType" TEXT;

View File

@ -0,0 +1,189 @@
/**
* Seed script for expertise tags
*
* Run with: npx tsx prisma/seed-expertise-tags.ts
* Or in Docker: docker exec mopc-app npx tsx prisma/seed-expertise-tags.ts
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
interface TagDefinition {
name: string
description: string
category: string
color: string
}
const EXPERTISE_TAGS: TagDefinition[] = [
// Marine Science & Biology
{ name: 'Marine Biology', description: 'Study of marine organisms and ecosystems', category: 'Marine Science', color: '#0ea5e9' },
{ name: 'Oceanography', description: 'Physical and chemical properties of the ocean', category: 'Marine Science', color: '#0284c7' },
{ name: 'Marine Ecology', description: 'Relationships between marine organisms and environment', category: 'Marine Science', color: '#0891b2' },
{ name: 'Fisheries Science', description: 'Management and conservation of fish populations', category: 'Marine Science', color: '#06b6d4' },
{ name: 'Marine Microbiology', description: 'Study of microorganisms in marine environments', category: 'Marine Science', color: '#22d3ee' },
{ name: 'Marine Genetics', description: 'Genetic research on marine species', category: 'Marine Science', color: '#67e8f9' },
{ name: 'Deep Sea Research', description: 'Exploration and study of deep ocean environments', category: 'Marine Science', color: '#164e63' },
// Conservation & Environment
{ name: 'Ocean Conservation', description: 'Protection and preservation of marine environments', category: 'Conservation', color: '#059669' },
{ name: 'Biodiversity', description: 'Marine species diversity and protection', category: 'Conservation', color: '#10b981' },
{ name: 'Coral Reef Restoration', description: 'Rehabilitation and protection of coral ecosystems', category: 'Conservation', color: '#f97316' },
{ name: 'Marine Protected Areas', description: 'Design and management of ocean reserves', category: 'Conservation', color: '#34d399' },
{ name: 'Species Conservation', description: 'Protection of endangered marine species', category: 'Conservation', color: '#6ee7b7' },
{ name: 'Habitat Restoration', description: 'Rehabilitation of damaged marine habitats', category: 'Conservation', color: '#a7f3d0' },
{ name: 'Wildlife Management', description: 'Management of marine wildlife populations', category: 'Conservation', color: '#047857' },
// Climate & Environment
{ name: 'Climate Science', description: 'Ocean-climate interactions and research', category: 'Climate', color: '#14b8a6' },
{ name: 'Climate Adaptation', description: 'Strategies for adapting to climate change impacts', category: 'Climate', color: '#2dd4bf' },
{ name: 'Climate Mitigation', description: 'Reducing greenhouse gas emissions', category: 'Climate', color: '#5eead4' },
{ name: 'Blue Carbon', description: 'Carbon sequestration in marine ecosystems', category: 'Climate', color: '#99f6e4' },
{ name: 'Ocean Acidification', description: 'Impact of CO2 on ocean chemistry', category: 'Climate', color: '#0d9488' },
{ name: 'Sea Level Rise', description: 'Causes and impacts of rising sea levels', category: 'Climate', color: '#115e59' },
// Pollution & Waste
{ name: 'Plastic Pollution', description: 'Marine plastic waste and solutions', category: 'Pollution', color: '#84cc16' },
{ name: 'Waste Management', description: 'Marine and coastal waste solutions', category: 'Pollution', color: '#65a30d' },
{ name: 'Microplastics Research', description: 'Study of microplastic impacts', category: 'Pollution', color: '#a3e635' },
{ name: 'Chemical Pollution', description: 'Industrial and agricultural runoff', category: 'Pollution', color: '#bef264' },
{ name: 'Oil Spill Response', description: 'Prevention and cleanup of oil spills', category: 'Pollution', color: '#4d7c0f' },
{ name: 'Water Quality', description: 'Monitoring and improving water quality', category: 'Pollution', color: '#3f6212' },
{ name: 'Circular Economy', description: 'Reducing waste through circular systems', category: 'Pollution', color: '#d9f99d' },
// Sustainable Industries
{ name: 'Sustainable Fishing', description: 'Environmentally responsible fishing practices', category: 'Sustainable Industries', color: '#22c55e' },
{ name: 'Aquaculture', description: 'Sustainable fish and seafood farming', category: 'Sustainable Industries', color: '#16a34a' },
{ name: 'Sustainable Shipping', description: 'Green maritime transportation', category: 'Sustainable Industries', color: '#6366f1' },
{ name: 'Sustainable Tourism', description: 'Eco-friendly coastal and marine tourism', category: 'Sustainable Industries', color: '#4f46e5' },
{ name: 'Blue Economy', description: 'Sustainable use of ocean resources', category: 'Sustainable Industries', color: '#3b82f6' },
{ name: 'Seaweed Industry', description: 'Sustainable seaweed cultivation and products', category: 'Sustainable Industries', color: '#15803d' },
{ name: 'Seafood Traceability', description: 'Supply chain transparency in seafood', category: 'Sustainable Industries', color: '#166534' },
// Technology & Innovation
{ name: 'Marine Technology', description: 'Technological solutions for ocean challenges', category: 'Technology', color: '#8b5cf6' },
{ name: 'Ocean Robotics', description: 'Autonomous underwater vehicles and drones', category: 'Technology', color: '#7c3aed' },
{ name: 'Remote Sensing', description: 'Satellite and sensor-based ocean monitoring', category: 'Technology', color: '#a78bfa' },
{ name: 'Data Science', description: 'Ocean data analysis and modeling', category: 'Technology', color: '#c4b5fd' },
{ name: 'AI & Machine Learning', description: 'Artificial intelligence for marine research', category: 'Technology', color: '#6d28d9' },
{ name: 'Biotechnology', description: 'Marine-derived biotechnology applications', category: 'Technology', color: '#5b21b6' },
{ name: 'Sensors & IoT', description: 'Internet of Things for ocean monitoring', category: 'Technology', color: '#4c1d95' },
{ name: 'Blockchain', description: 'Distributed ledger for transparency and traceability', category: 'Technology', color: '#ddd6fe' },
// Energy
{ name: 'Renewable Energy', description: 'Marine renewable energy sources', category: 'Energy', color: '#eab308' },
{ name: 'Offshore Wind', description: 'Wind energy from offshore installations', category: 'Energy', color: '#ca8a04' },
{ name: 'Wave Energy', description: 'Power generation from ocean waves', category: 'Energy', color: '#facc15' },
{ name: 'Tidal Energy', description: 'Power generation from tidal movements', category: 'Energy', color: '#fde047' },
{ name: 'Ocean Thermal Energy', description: 'OTEC and thermal gradient technologies', category: 'Energy', color: '#a16207' },
// Policy & Governance
{ name: 'Environmental Policy', description: 'Ocean and environmental policy development', category: 'Policy', color: '#a855f7' },
{ name: 'International Law', description: 'Law of the sea and maritime regulations', category: 'Policy', color: '#9333ea' },
{ name: 'Ocean Governance', description: 'Management and governance of ocean spaces', category: 'Policy', color: '#c084fc' },
{ name: 'Regulatory Compliance', description: 'Environmental regulations and compliance', category: 'Policy', color: '#e879f9' },
{ name: 'Stakeholder Engagement', description: 'Engaging communities and stakeholders', category: 'Policy', color: '#d946ef' },
{ name: 'Advocacy', description: 'Promoting ocean conservation causes', category: 'Policy', color: '#a21caf' },
// Business & Finance
{ name: 'Entrepreneurship', description: 'Ocean-focused startup development', category: 'Business', color: '#f43f5e' },
{ name: 'Investment & Finance', description: 'Sustainable ocean investment', category: 'Business', color: '#e11d48' },
{ name: 'Impact Investing', description: 'Investment for environmental impact', category: 'Business', color: '#fb7185' },
{ name: 'Business Development', description: 'Growing ocean-focused businesses', category: 'Business', color: '#fda4af' },
{ name: 'Social Enterprise', description: 'Businesses with social and environmental missions', category: 'Business', color: '#be123c' },
{ name: 'Carbon Markets', description: 'Carbon credit trading and blue carbon markets', category: 'Business', color: '#9f1239' },
// Education & Outreach
{ name: 'Education & Outreach', description: 'Ocean literacy and public education', category: 'Education', color: '#ec4899' },
{ name: 'Research & Academia', description: 'Academic research and publication', category: 'Education', color: '#db2777' },
{ name: 'Citizen Science', description: 'Public participation in scientific research', category: 'Education', color: '#f472b6' },
{ name: 'Youth Engagement', description: 'Engaging young people in ocean conservation', category: 'Education', color: '#f9a8d4' },
{ name: 'Media & Communications', description: 'Science communication and journalism', category: 'Education', color: '#be185d' },
{ name: 'Documentary Filmmaking', description: 'Visual storytelling for ocean conservation', category: 'Education', color: '#9d174d' },
// Coastal & Community
{ name: 'Coastal Management', description: 'Integrated coastal zone management', category: 'Coastal', color: '#f59e0b' },
{ name: 'Community Development', description: 'Supporting coastal communities', category: 'Coastal', color: '#d97706' },
{ name: 'Small-scale Fisheries', description: 'Supporting artisanal and local fishers', category: 'Coastal', color: '#fbbf24' },
{ name: 'Indigenous Knowledge', description: 'Traditional ecological knowledge', category: 'Coastal', color: '#fcd34d' },
{ name: 'Disaster Resilience', description: 'Coastal hazard preparedness and response', category: 'Coastal', color: '#b45309' },
{ name: 'Mangrove Conservation', description: 'Protection and restoration of mangroves', category: 'Coastal', color: '#92400e' },
// Specific Focus Areas
{ name: 'Arctic & Antarctic', description: 'Polar ocean research and conservation', category: 'Regions', color: '#0369a1' },
{ name: 'Mediterranean', description: 'Mediterranean Sea conservation', category: 'Regions', color: '#0c4a6e' },
{ name: 'Pacific Islands', description: 'Pacific island marine conservation', category: 'Regions', color: '#075985' },
{ name: 'Coral Triangle', description: 'Indo-Pacific coral reef biodiversity', category: 'Regions', color: '#0284c7' },
// Cross-cutting
{ name: 'Project Management', description: 'Managing conservation and research projects', category: 'Skills', color: '#64748b' },
{ name: 'Grant Writing', description: 'Securing funding through proposals', category: 'Skills', color: '#475569' },
{ name: 'Monitoring & Evaluation', description: 'Measuring project impact and outcomes', category: 'Skills', color: '#334155' },
{ name: 'GIS & Mapping', description: 'Geographic information systems for ocean data', category: 'Skills', color: '#1e293b' },
{ name: 'Scientific Diving', description: 'Underwater research and data collection', category: 'Skills', color: '#94a3b8' },
]
async function main() {
console.log('🏷️ Seeding expertise tags...\n')
// Get existing tags
const existingTags = await prisma.expertiseTag.findMany({
select: { name: true },
})
const existingNames = new Set(existingTags.map((t) => t.name))
// Filter out tags that already exist
const newTags = EXPERTISE_TAGS.filter((t) => !existingNames.has(t.name))
if (newTags.length === 0) {
console.log('✅ All tags already exist in database')
return
}
// Get max sort order
const maxOrder = await prisma.expertiseTag.aggregate({
_max: { sortOrder: true },
})
const startOrder = (maxOrder._max.sortOrder || 0) + 1
// Create tags
const result = await prisma.expertiseTag.createMany({
data: newTags.map((tag, index) => ({
name: tag.name,
description: tag.description,
category: tag.category,
color: tag.color,
sortOrder: startOrder + index,
isActive: true,
})),
})
console.log(`✅ Created ${result.count} new expertise tags`)
console.log(` Skipped ${existingNames.size} existing tags`)
// Print summary by category
const byCategory = newTags.reduce(
(acc, tag) => {
acc[tag.category] = (acc[tag.category] || 0) + 1
return acc
},
{} as Record<string, number>
)
console.log('\n📊 Tags created by category:')
Object.entries(byCategory)
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([category, count]) => {
console.log(` ${category}: ${count}`)
})
}
main()
.catch((e) => {
console.error('❌ Error seeding expertise tags:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -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">

View File

@ -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>
)

View File

@ -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
}