feat: add reusable BulkInviteForm component for multi-row name+email invites

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-07 20:11:45 -04:00
parent 158eba416d
commit 22a08ef957

View File

@@ -0,0 +1,131 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
import { Plus, Trash2, Send, Loader2 } from 'lucide-react'
interface InviteRow {
name: string
email: string
}
interface BulkInviteFormProps {
onSubmit: (rows: InviteRow[]) => Promise<void>
isPending?: boolean
submitLabel?: string
}
const emptyRow = (): InviteRow => ({ name: '', email: '' })
export function BulkInviteForm({
onSubmit,
isPending = false,
submitLabel = 'Send Invites',
}: BulkInviteFormProps) {
const [rows, setRows] = useState<InviteRow[]>([emptyRow()])
const updateRow = (index: number, field: keyof InviteRow, value: string) => {
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r)))
}
const addRow = () => setRows((prev) => [...prev, emptyRow()])
const removeRow = (index: number) => {
if (rows.length <= 1) return
setRows((prev) => prev.filter((_, i) => i !== index))
}
const validRows = rows.filter(
(r) => r.email.trim() && r.email.includes('@')
)
const handleSubmit = async () => {
if (validRows.length === 0) {
toast.error('Please enter at least one valid email address')
return
}
const emails = validRows.map((r) => r.email.trim().toLowerCase())
const dupes = emails.filter((e, i) => emails.indexOf(e) !== i)
if (dupes.length > 0) {
toast.error(`Duplicate email: ${dupes[0]}`)
return
}
await onSubmit(validRows.map((r) => ({ name: r.name.trim(), email: r.email.trim() })))
setRows([emptyRow()])
}
return (
<div className="space-y-3">
<div className="space-y-2">
{rows.map((row, index) => (
<div key={index} className="flex items-end gap-2">
<div className="flex-1 space-y-1">
{index === 0 && (
<Label className="text-xs text-muted-foreground">Name</Label>
)}
<Input
placeholder="Full name"
value={row.name}
onChange={(e) => updateRow(index, 'name', e.target.value)}
disabled={isPending}
/>
</div>
<div className="flex-1 space-y-1">
{index === 0 && (
<Label className="text-xs text-muted-foreground">
Email <span className="text-destructive">*</span>
</Label>
)}
<Input
type="email"
placeholder="email@example.com"
value={row.email}
onChange={(e) => updateRow(index, 'email', e.target.value)}
disabled={isPending}
/>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => removeRow(index)}
disabled={rows.length <= 1 || isPending}
className="shrink-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={addRow}
disabled={isPending}
>
<Plus className="mr-1.5 h-4 w-4" />
Add Row
</Button>
<Button
onClick={handleSubmit}
disabled={validRows.length === 0 || isPending}
size="sm"
>
{isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Send className="mr-1.5 h-4 w-4" />
)}
{submitLabel} ({validRows.length})
</Button>
</div>
</div>
)
}