'use client' import { useState, useCallback, useMemo } from 'react' import Link from 'next/link' import Papa from 'papaparse' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Progress } from '@/components/ui/progress' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { ArrowLeft, ArrowRight, AlertCircle, CheckCircle2, Loader2, Users, X, Plus, FileSpreadsheet, UserPlus, } from 'lucide-react' import { cn } from '@/lib/utils' type Step = 'input' | 'preview' | 'sending' | 'complete' type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' interface MemberRow { id: string name: string email: string role: Role expertiseTags: string[] tagInput: string } interface ParsedUser { email: string name?: string role: Role expertiseTags?: string[] isValid: boolean error?: string isDuplicate?: boolean } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const ROLE_LABELS: Record = { JURY_MEMBER: 'Jury Member', MENTOR: 'Mentor', OBSERVER: 'Observer', } let rowIdCounter = 0 function nextRowId(): string { return `row-${++rowIdCounter}` } function createEmptyRow(role: Role = 'JURY_MEMBER'): MemberRow { return { id: nextRowId(), name: '', email: '', role, expertiseTags: [], tagInput: '' } } // Common expertise tags for suggestions const SUGGESTED_TAGS = [ 'Marine Biology', 'Ocean Conservation', 'Coral Reef Restoration', 'Sustainable Fisheries', 'Marine Policy', 'Ocean Technology', 'Climate Science', 'Biodiversity', 'Blue Economy', 'Coastal Management', 'Oceanography', 'Marine Pollution', 'Plastic Reduction', 'Renewable Energy', 'Business Development', 'Impact Investment', 'Social Entrepreneurship', 'Startup Mentoring', ] export default function MemberInvitePage() { const [step, setStep] = useState('input') const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual') const [rows, setRows] = useState([createEmptyRow()]) const [parsedUsers, setParsedUsers] = useState([]) const [sendProgress, setSendProgress] = useState(0) const [result, setResult] = useState<{ created: number skipped: number } | null>(null) const utils = trpc.useUtils() const bulkCreate = trpc.user.bulkCreate.useMutation({ onSuccess: () => { // Invalidate user list to refresh the members table when navigating back utils.user.list.invalidate() }, }) // --- Manual entry helpers --- const updateRow = (id: string, field: keyof MemberRow, value: string | string[]) => { setRows((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: value } : r)) ) } const removeRow = (id: string) => { setRows((prev) => { const filtered = prev.filter((r) => r.id !== id) return filtered.length === 0 ? [createEmptyRow()] : filtered }) } const addRow = () => { const lastRole = rows[rows.length - 1]?.role || 'JURY_MEMBER' setRows((prev) => [...prev, createEmptyRow(lastRole)]) } // Per-row tag management const addTagToRow = (id: string, tag: string) => { const trimmed = tag.trim() if (!trimmed) return setRows((prev) => prev.map((r) => { if (r.id !== id) return r if (r.expertiseTags.includes(trimmed)) return r return { ...r, expertiseTags: [...r.expertiseTags, trimmed], tagInput: '' } }) ) } const removeTagFromRow = (id: string, tag: string) => { setRows((prev) => prev.map((r) => r.id === id ? { ...r, expertiseTags: r.expertiseTags.filter((t) => t !== tag) } : r ) ) } // Get suggestions that haven't been added yet for a specific row const getSuggestionsForRow = (row: MemberRow) => { return SUGGESTED_TAGS.filter( (tag) => !row.expertiseTags.includes(tag) && tag.toLowerCase().includes(row.tagInput.toLowerCase()) ).slice(0, 5) } // --- CSV helpers --- const handleCSVUpload = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return Papa.parse>(file, { header: true, skipEmptyLines: true, complete: (results) => { const seenEmails = new Set() const users: ParsedUser[] = results.data.map((row) => { const emailKey = Object.keys(row).find( (key) => key.toLowerCase() === 'email' || key.toLowerCase().includes('email') ) const nameKey = Object.keys(row).find( (key) => key.toLowerCase() === 'name' || key.toLowerCase().includes('name') ) const roleKey = Object.keys(row).find( (key) => key.toLowerCase() === 'role' || key.toLowerCase().includes('role') ) const email = emailKey ? row[emailKey]?.trim().toLowerCase() : '' const name = nameKey ? row[nameKey]?.trim() : undefined const rawRole = roleKey ? row[roleKey]?.trim().toUpperCase() : '' const role: Role = rawRole === 'MENTOR' ? 'MENTOR' : rawRole === 'OBSERVER' ? 'OBSERVER' : 'JURY_MEMBER' const isValidFormat = emailRegex.test(email) const isDuplicate = email ? seenEmails.has(email) : false if (isValidFormat && !isDuplicate && email) seenEmails.add(email) return { email, name, role, isValid: isValidFormat && !isDuplicate, isDuplicate, error: !email ? 'No email found' : !isValidFormat ? 'Invalid email format' : isDuplicate ? 'Duplicate email' : undefined, } }) setParsedUsers(users.filter((u) => u.email)) setStep('preview') }, }) }, [] ) // --- Parse manual rows into ParsedUser format --- const parseManualRows = (): ParsedUser[] => { const seenEmails = new Set() return rows .filter((r) => r.email.trim()) .map((r) => { const email = r.email.trim().toLowerCase() const isValidFormat = emailRegex.test(email) const isDuplicate = seenEmails.has(email) if (isValidFormat && !isDuplicate) seenEmails.add(email) return { email, name: r.name.trim() || undefined, role: r.role, expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined, isValid: isValidFormat && !isDuplicate, isDuplicate, error: !isValidFormat ? 'Invalid email format' : isDuplicate ? 'Duplicate email' : undefined, } }) } const handleManualProceed = () => { const parsed = parseManualRows() if (parsed.length === 0) return setParsedUsers(parsed) setStep('preview') } // --- Summary --- const summary = useMemo(() => { const validUsers = parsedUsers.filter((u) => u.isValid) const invalidUsers = parsedUsers.filter((u) => !u.isValid) return { total: parsedUsers.length, valid: validUsers.length, invalid: invalidUsers.length, validUsers, invalidUsers, } }, [parsedUsers]) const removeInvalidUsers = () => setParsedUsers(parsedUsers.filter((u) => u.isValid)) // --- Send --- const handleSendInvites = async () => { if (summary.valid === 0) return setStep('sending') setSendProgress(0) try { const result = await bulkCreate.mutateAsync({ users: summary.validUsers.map((u) => ({ email: u.email, name: u.name, role: u.role, expertiseTags: u.expertiseTags, })), }) setSendProgress(100) setResult(result) setStep('complete') } catch { setStep('preview') } } const resetForm = () => { setStep('input') setRows([createEmptyRow()]) setParsedUsers([]) setResult(null) setSendProgress(0) } const hasManualData = rows.some((r) => r.email.trim() || r.name.trim()) const steps: Array<{ key: Step; label: string }> = [ { key: 'input', label: 'Input' }, { key: 'preview', label: 'Preview' }, { key: 'sending', label: 'Send' }, { key: 'complete', label: 'Done' }, ] const currentStepIndex = steps.findIndex((s) => s.key === step) const renderStep = () => { switch (step) { case 'input': return ( Invite Members Add members individually or upload a CSV file {/* Method toggle */}
{inputMethod === 'manual' ? (
{/* Member cards */} {rows.map((row, index) => (
Member {index + 1}
updateRow(row.id, 'name', e.target.value) } /> updateRow(row.id, 'email', e.target.value) } />
{/* Per-member expertise tags */}
updateRow(row.id, 'tagInput', e.target.value) } onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault() addTagToRow(row.id, row.tagInput) } }} />
{/* Tag suggestions */} {row.tagInput && getSuggestionsForRow(row).length > 0 && (
{getSuggestionsForRow(row).map((suggestion) => ( ))}
)} {/* Quick suggestions when empty */} {!row.tagInput && row.expertiseTags.length === 0 && (
Suggestions: {SUGGESTED_TAGS.slice(0, 5).map((suggestion) => ( ))}
)} {/* Added tags */} {row.expertiseTags.length > 0 && (
{row.expertiseTags.map((tag) => ( {tag} ))}
)}
))}
) : (

CSV should have columns: name, email, role (optional)

document.getElementById('csv-input')?.click() } >

Drop CSV file here or click to browse

)} {/* Actions */}
{inputMethod === 'manual' && ( )}
) case 'preview': return ( Preview Invitations Review the list of members to invite

{summary.total}

Total

{summary.valid}

Valid

{summary.invalid}

Invalid

{summary.invalid > 0 && (

{summary.invalid} email(s) have issues

)}
Name Email Role Status {parsedUsers.map((user, index) => ( {user.name || '-'} {user.email} {ROLE_LABELS[user.role]} {user.isValid ? ( Valid ) : ( {user.error} )} ))}
{bulkCreate.error && (
{bulkCreate.error.message}
)}
) case 'sending': return (

Creating members and sending invitations...

) case 'complete': return (

Invitations Sent!

{result?.created} member{result?.created !== 1 ? 's' : ''}{' '} created and invited. {result?.skipped ? ` ${result.skipped} skipped (already exist).` : ''}

) } } return (

Invite Members

Add new members to the platform

{/* Step indicator */}
{steps.map((s, index) => (
{index > 0 && (
)}
{index + 1}
))}
{renderStep()}
) }