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:
131
src/components/shared/bulk-invite-form.tsx
Normal file
131
src/components/shared/bulk-invite-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user