Add unified expertise tag system and round entry notifications
Build and Push Docker Image / build (push) Successful in 9m14s
Details
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:
parent
41a36f72b3
commit
8cdf6c9e5e
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- AddRoundEntryNotification
|
||||||
|
-- Adds the entryNotificationType column to Round table for configurable notifications
|
||||||
|
|
||||||
|
ALTER TABLE "Round" ADD COLUMN "entryNotificationType" TEXT;
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -39,15 +39,44 @@ type Step = 'name' | 'phone' | 'tags' | 'preferences' | 'complete'
|
||||||
export default function OnboardingPage() {
|
export default function OnboardingPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [step, setStep] = useState<Step>('name')
|
const [step, setStep] = useState<Step>('name')
|
||||||
|
const [initialized, setInitialized] = useState(false)
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [phoneNumber, setPhoneNumber] = useState('')
|
const [phoneNumber, setPhoneNumber] = useState('')
|
||||||
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
||||||
|
const [lockedTags, setLockedTags] = useState<string[]>([])
|
||||||
const [notificationPreference, setNotificationPreference] = useState<
|
const [notificationPreference, setNotificationPreference] = useState<
|
||||||
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
|
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
|
||||||
>('EMAIL')
|
>('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
|
// Fetch feature flags
|
||||||
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery()
|
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery()
|
||||||
const whatsappEnabled = featureFlags?.whatsappEnabled ?? false
|
const whatsappEnabled = featureFlags?.whatsappEnabled ?? false
|
||||||
|
|
@ -95,15 +124,36 @@ export default function OnboardingPage() {
|
||||||
setStep('complete')
|
setStep('complete')
|
||||||
toast.success('Welcome to MOPC!')
|
toast.success('Welcome to MOPC!')
|
||||||
|
|
||||||
// Redirect after a short delay
|
// Redirect after a short delay based on user role
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
const role = userData?.role
|
||||||
|
if (role === 'MENTOR') {
|
||||||
|
router.push('/mentor')
|
||||||
|
} else if (role === 'OBSERVER') {
|
||||||
|
router.push('/observer')
|
||||||
|
} else {
|
||||||
router.push('/jury')
|
router.push('/jury')
|
||||||
|
}
|
||||||
}, 2000)
|
}, 2000)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to complete onboarding')
|
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 (
|
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]">
|
<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">
|
<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}
|
value={expertiseTags}
|
||||||
onChange={setExpertiseTags}
|
onChange={setExpertiseTags}
|
||||||
maxTags={5}
|
maxTags={5}
|
||||||
|
lockedTags={lockedTags}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,12 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Check, X } from 'lucide-react'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Check, X, Lock, Search, Loader2, ChevronDown, ChevronRight } 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' },
|
|
||||||
]
|
|
||||||
|
|
||||||
interface ExpertiseSelectProps {
|
interface ExpertiseSelectProps {
|
||||||
value: string[]
|
value: string[]
|
||||||
|
|
@ -34,6 +14,7 @@ interface ExpertiseSelectProps {
|
||||||
maxTags?: number
|
maxTags?: number
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
lockedTags?: string[] // Tags set by admin that cannot be removed
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExpertiseSelect({
|
export function ExpertiseSelect({
|
||||||
|
|
@ -42,46 +23,141 @@ export function ExpertiseSelect({
|
||||||
maxTags = 10,
|
maxTags = 10,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
|
lockedTags = [],
|
||||||
}: ExpertiseSelectProps) {
|
}: 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) => {
|
const handleToggle = (name: string) => {
|
||||||
if (disabled) return
|
if (disabled || isTagLocked(name)) return
|
||||||
|
|
||||||
if (value.includes(name)) {
|
if (value.includes(name)) {
|
||||||
onChange(value.filter((t) => t !== name))
|
onChange(value.filter((t) => t !== name))
|
||||||
} else {
|
} 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])
|
onChange([...value, name])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemove = (name: string) => {
|
const handleRemove = (name: string) => {
|
||||||
if (disabled) return
|
if (disabled || isTagLocked(name)) return
|
||||||
onChange(value.filter((t) => t !== name))
|
onChange(value.filter((t) => t !== name))
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOption = (name: string) =>
|
const toggleCategory = (category: string) => {
|
||||||
EXPERTISE_OPTIONS.find((o) => o.name === name)
|
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 (
|
return (
|
||||||
<div className={cn('space-y-4', className)}>
|
<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 */}
|
{/* Selected tags at the top */}
|
||||||
{value.length > 0 && (
|
{value.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{value.map((name) => {
|
{value.map((name) => {
|
||||||
const option = getOption(name)
|
const tag = getTag(name)
|
||||||
|
const isLocked = isTagLocked(name)
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
key={name}
|
key={name}
|
||||||
variant="secondary"
|
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={{
|
style={{
|
||||||
backgroundColor: option?.color ? `${option.color}15` : undefined,
|
backgroundColor: tag?.color ? `${tag.color}15` : undefined,
|
||||||
borderColor: option?.color || undefined,
|
borderColor: tag?.color || undefined,
|
||||||
color: option?.color || undefined,
|
color: tag?.color || undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{isLocked && <Lock className="h-3 w-3 mr-0.5" />}
|
||||||
{name}
|
{name}
|
||||||
{!disabled && (
|
{!disabled && !isLocked && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemove(name)}
|
onClick={() => handleRemove(name)}
|
||||||
|
|
@ -97,52 +173,115 @@ export function ExpertiseSelect({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Grid of options */}
|
{/* Locked tags notice */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
{lockedTags.length > 0 && (
|
||||||
{EXPERTISE_OPTIONS.map((option) => {
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
const isSelected = value.includes(option.name)
|
<Lock className="h-3 w-3" />
|
||||||
const isDisabled = disabled || (!isSelected && value.length >= maxTags)
|
Tags with a lock icon were pre-selected by your administrator
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={option.id}
|
key={tag.id}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isDisabled}
|
disabled={isDisabledOption && !isSelected}
|
||||||
onClick={() => handleToggle(option.name)}
|
onClick={() => handleToggle(tag.name)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'justify-start h-auto py-2 px-3 text-left font-normal transition-all',
|
'justify-start h-auto py-1.5 px-2 text-left font-normal transition-all',
|
||||||
isSelected && 'ring-2 ring-offset-1',
|
isLocked && 'cursor-not-allowed',
|
||||||
isDisabled && !isSelected && 'opacity-50'
|
isDisabledOption && !isSelected && 'opacity-50'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
borderColor: isSelected ? option.color : undefined,
|
borderColor: isSelected ? tag.color || undefined : undefined,
|
||||||
ringColor: option.color,
|
backgroundColor: isSelected ? `${tag.color}10` : undefined,
|
||||||
backgroundColor: isSelected ? `${option.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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-4 w-4 rounded border-2 mr-2 flex items-center justify-center transition-colors',
|
'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'
|
isSelected
|
||||||
|
? 'border-current bg-current'
|
||||||
|
: 'border-muted-foreground/30'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
borderColor: isSelected ? option.color : undefined,
|
borderColor: isSelected ? tag.color || undefined : undefined,
|
||||||
backgroundColor: isSelected ? option.color : undefined,
|
backgroundColor: isSelected ? tag.color || undefined : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isSelected && <Check className="h-3 w-3 text-white" />}
|
{isSelected &&
|
||||||
|
(isLocked ? (
|
||||||
|
<Lock className="h-2 w-2 text-white" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-2 w-2 text-white" />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm">{option.name}</span>
|
<span className="text-xs truncate">{tag.name}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</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 */}
|
{/* Counter */}
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -734,12 +734,23 @@ export const userRouter = router({
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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({
|
const user = await ctx.prisma.user.update({
|
||||||
where: { id: ctx.user.id },
|
where: { id: ctx.user.id },
|
||||||
data: {
|
data: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
phoneNumber: input.phoneNumber,
|
phoneNumber: input.phoneNumber,
|
||||||
expertiseTags: input.expertiseTags || [],
|
expertiseTags: mergedTags,
|
||||||
notificationPreference: input.notificationPreference || 'EMAIL',
|
notificationPreference: input.notificationPreference || 'EMAIL',
|
||||||
onboardingCompletedAt: new Date(),
|
onboardingCompletedAt: new Date(),
|
||||||
status: 'ACTIVE', // Activate user after onboarding
|
status: 'ACTIVE', // Activate user after onboarding
|
||||||
|
|
@ -771,8 +782,9 @@ export const userRouter = router({
|
||||||
select: { onboardingCompletedAt: true, role: true },
|
select: { onboardingCompletedAt: true, role: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Only jury members need onboarding
|
// Jury members and mentors need onboarding
|
||||||
if (user.role !== 'JURY_MEMBER') {
|
const rolesRequiringOnboarding = ['JURY_MEMBER', 'MENTOR']
|
||||||
|
if (!rolesRequiringOnboarding.includes(user.role)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue