'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 } interface ParsedUser { email: string name?: string role: Role 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 } } export default function MemberInvitePage() { const [step, setStep] = useState('input') const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual') const [rows, setRows] = useState([createEmptyRow()]) const [expertiseTags, setExpertiseTags] = useState([]) const [tagInput, setTagInput] = useState('') const [parsedUsers, setParsedUsers] = useState([]) const [sendProgress, setSendProgress] = useState(0) const [result, setResult] = useState<{ created: number skipped: number } | null>(null) const bulkCreate = trpc.user.bulkCreate.useMutation() // --- Manual entry helpers --- const updateRow = (id: string, field: keyof MemberRow, value: 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)]) } // --- 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, 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') } // --- Tags --- const addTag = () => { const tag = tagInput.trim() if (tag && !expertiseTags.includes(tag)) { setExpertiseTags([...expertiseTags, tag]) setTagInput('') } } const removeTag = (tag: string) => setExpertiseTags(expertiseTags.filter((t) => t !== tag)) // --- 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: expertiseTags.length > 0 ? expertiseTags : undefined, })), }) 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' ? (
{/* Column headers */}
{/* Rows */} {rows.map((row) => (
updateRow(row.id, 'name', e.target.value) } /> updateRow(row.id, 'email', e.target.value) } />
))}
) : (

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

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

Drop CSV file here or click to browse

)} {/* Expertise tags */}
setTagInput(e.target.value)} placeholder="e.g., Marine Biology" onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() addTag() } }} />
{expertiseTags.length > 0 && (
{expertiseTags.map((tag) => ( {tag} ))}
)}
{/* 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()}
) }