add upload contacts support (#314)
This commit is contained in:
@@ -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<string | null>(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<string, ParsedContact>();
|
||||||
|
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Upload className="h-4 w-4 mr-1" />
|
||||||
|
Upload Contacts
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bulk Upload Contacts</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload multiple contacts at once. Supports email only or CSV format
|
||||||
|
(email,firstName,lastName).
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Tabs defaultValue="text" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="text">
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
Text Input
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="file">
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
File Upload
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="text" className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="contacts">Contacts</Label>
|
||||||
|
<Textarea
|
||||||
|
id="contacts"
|
||||||
|
placeholder={`Enter contacts, one per line:
|
||||||
|
|
||||||
|
john@example.com,John,Doe
|
||||||
|
jane@example.com,Jane,Smith
|
||||||
|
bob@example.com
|
||||||
|
|
||||||
|
Format: email,firstName,lastName (firstName and lastName are optional)`}
|
||||||
|
value={inputText}
|
||||||
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
|
className="min-h-[150px] font-mono text-sm"
|
||||||
|
disabled={processing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="file" className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="file">Upload File</Label>
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-lg p-6 transition-colors ${
|
||||||
|
isDragOver
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-muted-foreground/25"
|
||||||
|
}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="file"
|
||||||
|
type="file"
|
||||||
|
accept=".txt,.csv"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
disabled={processing}
|
||||||
|
/>
|
||||||
|
<div className="text-center">
|
||||||
|
<Upload
|
||||||
|
className={`mx-auto h-12 w-12 ${
|
||||||
|
isDragOver ? "text-primary" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => document.getElementById("file")?.click()}
|
||||||
|
disabled={processing}
|
||||||
|
>
|
||||||
|
Choose File
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{isDragOver
|
||||||
|
? "Drop your file here"
|
||||||
|
: "Upload a .txt or .csv file or drag and drop here"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Format: email,firstName,lastName (one per line)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Preview Table */}
|
||||||
|
{previewContacts.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
Preview (showing {previewContacts.length} of{" "}
|
||||||
|
{parsedContacts.length})
|
||||||
|
</Label>
|
||||||
|
<div className="border rounded-lg max-h-[250px] overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/30">
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>First Name</TableHead>
|
||||||
|
<TableHead>Last Name</TableHead>
|
||||||
|
<TableHead className="w-[80px]">Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{previewContacts.map((contact, index) => (
|
||||||
|
<TableRow key={`${contact.email}-${index}`}>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{contact.email}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{contact.firstName || (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{contact.lastName || (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{contact.isValid ? (
|
||||||
|
<div className="flex items-center text-green">
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
<span className="text-xs">Valid</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center text-red">
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
<span className="text-xs">Invalid</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary counts */}
|
||||||
|
{parsedContacts.length > 0 && (
|
||||||
|
<div className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<span>Total: {parsedContacts.length}</span>
|
||||||
|
<span className="text-green">
|
||||||
|
Valid: {validContacts.length}
|
||||||
|
</span>
|
||||||
|
{invalidCount > 0 && (
|
||||||
|
<span className="text-red">Invalid: {invalidCount}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={processing}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={processing || validContacts.length === 0}
|
||||||
|
>
|
||||||
|
{processing
|
||||||
|
? "Uploading..."
|
||||||
|
: `Upload ${validContacts.length} Contacts`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@usesend/ui/src/breadcrumb";
|
} from "@usesend/ui/src/breadcrumb";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import AddContact from "./add-contact";
|
import AddContact from "./add-contact";
|
||||||
|
import BulkUploadContacts from "./bulk-upload-contacts";
|
||||||
import ContactList from "./contact-list";
|
import ContactList from "./contact-list";
|
||||||
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
|
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
@@ -120,6 +121,7 @@ export default function ContactsPage({
|
|||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
|
<BulkUploadContacts contactBookId={contactBookId} />
|
||||||
<AddContact contactBookId={contactBookId} />
|
<AddContact contactBookId={contactBookId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -119,7 +119,8 @@ export const contactsRouter = createTRPCRouter({
|
|||||||
addContacts: contactBookProcedure
|
addContacts: contactBookProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
contacts: z.array(
|
contacts: z
|
||||||
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
firstName: z.string().optional(),
|
firstName: z.string().optional(),
|
||||||
@@ -127,7 +128,8 @@ export const contactsRouter = createTRPCRouter({
|
|||||||
properties: z.record(z.string()).optional(),
|
properties: z.record(z.string()).optional(),
|
||||||
subscribed: z.boolean().optional(),
|
subscribed: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
|
.max(10000),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx: { contactBook, team }, input }) => {
|
.mutation(async ({ ctx: { contactBook, team }, input }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user