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