diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx new file mode 100644 index 0000000..44a83bd --- /dev/null +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx @@ -0,0 +1,445 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { api } from "~/trpc/react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@usesend/ui/src/dialog"; +import { Button } from "@usesend/ui/src/button"; +import { Label } from "@usesend/ui/src/label"; +import { Textarea } from "@usesend/ui/src/textarea"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@usesend/ui/src/tabs"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@usesend/ui/src/table"; +import { Upload, FileText, Check, X } from "lucide-react"; +import { toast } from "@usesend/ui/src/toaster"; + +interface BulkUploadContactsProps { + contactBookId: string; +} + +interface ParsedContact { + email: string; + firstName?: string; + lastName?: string; + isValid: boolean; +} + +export default function BulkUploadContacts({ + contactBookId, +}: BulkUploadContactsProps) { + const [open, setOpen] = useState(false); + const [inputText, setInputText] = useState(""); + const [error, setError] = useState(null); + const [processing, setProcessing] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + + const utils = api.useUtils(); + + const addContactsMutation = api.contacts.addContacts.useMutation({ + onSuccess: (result) => { + utils.contacts.contacts.invalidate(); + utils.contacts.getContactBookDetails.invalidate(); + setProcessing(false); + handleClose(); + toast.success(result.message); + }, + onError: (error) => { + setError(error.message); + setProcessing(false); + }, + }); + + const handleClose = () => { + setInputText(""); + setError(null); + setProcessing(false); + setOpen(false); + }; + + const validateEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const parseContactLine = ( + line: string, + ): { email: string; firstName?: string; lastName?: string } | null => { + const trimmedLine = line.trim(); + if (!trimmedLine) return null; + + // Split by comma + const parts = trimmedLine.split(",").map((s) => s.trim()); + + if (parts.length === 0 || !parts[0]) return null; + + const email = parts[0].toLowerCase(); + + // Skip if doesn't look like an email + if (!email.includes("@")) return null; + + if (parts.length === 1) { + // Just email + return { email }; + } else if (parts.length === 2) { + // email,firstName + return { + email, + firstName: parts[1] || undefined, + }; + } else { + // email,firstName,lastName (ignore anything beyond) + return { + email, + firstName: parts[1] || undefined, + lastName: parts[2] || undefined, + }; + } + }; + + const parseContacts = (text: string): ParsedContact[] => { + const lines = text.split("\n"); + const contactsMap = new Map(); + + for (const line of lines) { + const parsed = parseContactLine(line); + if (parsed) { + // Use email as key to deduplicate + if (!contactsMap.has(parsed.email)) { + contactsMap.set(parsed.email, { + ...parsed, + isValid: validateEmail(parsed.email), + }); + } + } + } + + return Array.from(contactsMap.values()); + }; + + const processFile = (file: File) => { + // Validate file type + if (!file.name.endsWith(".txt") && !file.name.endsWith(".csv")) { + setError("Please upload a .txt or .csv file"); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result as string; + setInputText(text); + setError(null); + }; + reader.readAsText(file); + }; + + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + processFile(file); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + const files = e.dataTransfer.files; + if (files.length > 0 && files[0]) { + processFile(files[0]); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setProcessing(true); + + if (!inputText.trim()) { + setError("Please enter contact information or upload a file"); + setProcessing(false); + return; + } + + const parsedContacts = parseContacts(inputText); + + if (parsedContacts.length === 0) { + setError("No valid contacts found"); + setProcessing(false); + return; + } + + const validContacts = parsedContacts.filter((c) => c.isValid); + + if (validContacts.length === 0) { + setError("No valid email addresses found"); + setProcessing(false); + return; + } + + if (validContacts.length > 10000) { + setError("Maximum 10,000 contacts allowed per upload"); + setProcessing(false); + return; + } + + if (validContacts.length !== parsedContacts.length) { + const invalidCount = parsedContacts.length - validContacts.length; + setError(`${invalidCount} invalid entries will be skipped`); + // Continue processing with valid contacts + } + + try { + await addContactsMutation.mutateAsync({ + contactBookId, + contacts: validContacts.map((c) => ({ + email: c.email, + firstName: c.firstName, + lastName: c.lastName, + })), + }); + } catch { + setProcessing(false); + } + }; + + const parsedContacts = useMemo(() => parseContacts(inputText), [inputText]); + + const validContacts = useMemo( + () => parsedContacts.filter((c) => c.isValid), + [parsedContacts], + ); + + const invalidCount = useMemo( + () => parsedContacts.length - validContacts.length, + [parsedContacts.length, validContacts.length], + ); + + const previewContacts = useMemo( + () => parsedContacts.slice(0, 20), + [parsedContacts], + ); + + return ( + + + + + + + Bulk Upload Contacts + + Upload multiple contacts at once. Supports email only or CSV format + (email,firstName,lastName). + + + +
+ + + + + Text Input + + + + File Upload + + + + +
+ +