From 0b3c2b6804312e567af6ba9eed37e53c72abb7f8 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 2 Feb 2026 20:07:03 +0100 Subject: [PATCH] Redesign member invite page with per-member form rows Replace bulk email textarea with individual name/email/role rows. Each member gets their own entry with full name, email, and role selector. New rows inherit the role from the previous row. CSV import kept as an alternative tab. Invites sent automatically on creation. Co-Authored-By: Claude Opus 4.5 --- src/app/(admin)/admin/members/invite/page.tsx | 632 ++++++++++++++---- 1 file changed, 518 insertions(+), 114 deletions(-) diff --git a/src/app/(admin)/admin/members/invite/page.tsx b/src/app/(admin)/admin/members/invite/page.tsx index 1db3476..672c4c1 100644 --- a/src/app/(admin)/admin/members/invite/page.tsx +++ b/src/app/(admin)/admin/members/invite/page.tsx @@ -2,7 +2,6 @@ import { useState, useCallback, useMemo } from 'react' import Link from 'next/link' -import { useRouter } from 'next/navigation' import Papa from 'papaparse' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' @@ -15,7 +14,6 @@ import { } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Textarea } from '@/components/ui/textarea' import { Badge } from '@/components/ui/badge' import { Progress } from '@/components/ui/progress' import { @@ -41,17 +39,26 @@ import { Loader2, Users, X, - Mail, + 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 @@ -59,94 +66,213 @@ interface ParsedUser { 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 router = useRouter() const [step, setStep] = useState('input') - const [inputMethod, setInputMethod] = useState<'textarea' | 'csv'>('textarea') - const [emailsText, setEmailsText] = useState('') - const [csvFile, setCsvFile] = useState(null) - const [role, setRole] = useState('JURY_MEMBER') + 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 [result, setResult] = useState<{ + created: number + skipped: number + } | null>(null) const bulkCreate = trpc.user.bulkCreate.useMutation() - const parseEmailsFromText = useCallback((text: string): ParsedUser[] => { - const lines = text.split(/[\n,;]+/).map((line) => line.trim()).filter(Boolean) + // --- 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 lines.map((line) => { - const matchWithName = line.match(/^(.+?)\s*<(.+?)>$/) - const email = matchWithName ? matchWithName[2].trim().toLowerCase() : line.toLowerCase() - const name = matchWithName ? matchWithName[1].trim() : undefined - const isValidFormat = emailRegex.test(email) - const isDuplicate = seenEmails.has(email) - if (isValidFormat && !isDuplicate) seenEmails.add(email) - return { - email, name, isValid: isValidFormat && !isDuplicate, isDuplicate, - error: !isValidFormat ? 'Invalid email format' : isDuplicate ? 'Duplicate email' : undefined, - } - }) - }, []) + 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 handleCSVUpload = useCallback((e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - setCsvFile(file) - 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 email = emailKey ? row[emailKey]?.trim().toLowerCase() : '' - const name = nameKey ? row[nameKey]?.trim() : undefined - const isValidFormat = emailRegex.test(email) - const isDuplicate = email ? seenEmails.has(email) : false - if (isValidFormat && !isDuplicate && email) seenEmails.add(email) - return { - email, name, 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') - }, - }) - }, []) + const handleManualProceed = () => { + const parsed = parseManualRows() + if (parsed.length === 0) return + setParsedUsers(parsed) + setStep('preview') + } - const handleTextProceed = () => { setParsedUsers(parseEmailsFromText(emailsText)); setStep('preview') } - 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)) + // --- 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) - const duplicateUsers = parsedUsers.filter((u) => u.isDuplicate) - return { total: parsedUsers.length, valid: validUsers.length, invalid: invalidUsers.length, duplicates: duplicateUsers.length, validUsers, invalidUsers, duplicateUsers } + return { + total: parsedUsers.length, + valid: validUsers.length, + invalid: invalidUsers.length, + validUsers, + invalidUsers, + } }, [parsedUsers]) - const removeInvalidUsers = () => setParsedUsers(parsedUsers.filter((u) => u.isValid)) + const removeInvalidUsers = () => + setParsedUsers(parsedUsers.filter((u) => u.isValid)) + // --- Send --- const handleSendInvites = async () => { if (summary.valid === 0) return - setStep('sending'); setSendProgress(0) + setStep('sending') + setSendProgress(0) try { const result = await bulkCreate.mutateAsync({ - users: summary.validUsers.map((u) => ({ email: u.email, name: u.name, role, expertiseTags: expertiseTags.length > 0 ? expertiseTags : undefined })), + 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') } + setSendProgress(100) + setResult(result) + setStep('complete') + } catch { + setStep('preview') + } } - const resetForm = () => { setStep('input'); setEmailsText(''); setCsvFile(null); setParsedUsers([]); setResult(null); setSendProgress(0) } + 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' }, + { key: 'input', label: 'Input' }, + { key: 'preview', label: 'Preview' }, + { key: 'sending', label: 'Send' }, + { key: 'complete', label: 'Done' }, ] const currentStepIndex = steps.findIndex((s) => s.key === step) @@ -157,114 +283,361 @@ export default function MemberInvitePage() { Invite Members - Add email addresses to invite new members to the platform + + Add members individually or upload a CSV file + + {/* Method toggle */}
- - + +
- {inputMethod === 'textarea' ? ( -
- -