Add contact creation and import features
This commit is contained in:
parent
11f7a43d1d
commit
10b9581c49
|
|
@ -19,6 +19,7 @@
|
||||||
"@radix-ui/react-toast": "^1.1.5",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"csv-parse": "^5.6.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"framer-motion": "^11.0.5",
|
"framer-motion": "^11.0.5",
|
||||||
"lucide-react": "^0.331.0",
|
"lucide-react": "^0.331.0",
|
||||||
|
|
@ -2948,6 +2949,12 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/csv-parse": {
|
||||||
|
"version": "5.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
|
||||||
|
"integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"@radix-ui/react-toast": "^1.1.5",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"csv-parse": "^5.6.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"framer-motion": "^11.0.5",
|
"framer-motion": "^11.0.5",
|
||||||
"lucide-react": "^0.331.0",
|
"lucide-react": "^0.331.0",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getUser } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { parse } from "csv-parse/sync";
|
||||||
|
|
||||||
|
// Max file size (5MB)
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Check authentication
|
||||||
|
const user = await getUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unauthorized" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the form data
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get("file") as File;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No file uploaded" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "File size exceeds 5MB limit" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file type
|
||||||
|
if (!file.name.endsWith(".csv") && file.type !== "text/csv") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Please upload a CSV file" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse the CSV data
|
||||||
|
const csvText = await file.text();
|
||||||
|
const records = parse(csvText, {
|
||||||
|
columns: true,
|
||||||
|
skip_empty_lines: true,
|
||||||
|
trim: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process the records
|
||||||
|
const stats = {
|
||||||
|
total: records.length,
|
||||||
|
imported: 0,
|
||||||
|
skipped: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
// Map CSV headers to database fields (case-insensitive)
|
||||||
|
const getField = (fieldName: string) => {
|
||||||
|
const normalizedFieldName = fieldName.toLowerCase();
|
||||||
|
for (const key of Object.keys(record)) {
|
||||||
|
if (key.toLowerCase() === normalizedFieldName) {
|
||||||
|
return record[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ethereumAddress = getField("ethereum address") || getField("address");
|
||||||
|
|
||||||
|
// Skip record if no Ethereum address is provided
|
||||||
|
if (!ethereumAddress) {
|
||||||
|
stats.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if contact already exists
|
||||||
|
const existingContact = await prisma.contact.findFirst({
|
||||||
|
where: {
|
||||||
|
ethereumAddress: ethereumAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingContact) {
|
||||||
|
stats.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the contact
|
||||||
|
await prisma.contact.create({
|
||||||
|
data: {
|
||||||
|
name: getField("name"),
|
||||||
|
ethereumAddress: ethereumAddress,
|
||||||
|
ensName: getField("ens name") || getField("ens"),
|
||||||
|
email: getField("email"),
|
||||||
|
twitter: getField("twitter"),
|
||||||
|
discord: getField("discord"),
|
||||||
|
telegram: getField("telegram"),
|
||||||
|
farcaster: getField("farcaster"),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
stats.imported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(stats, { status: 200 });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error importing contacts:", error);
|
||||||
|
|
||||||
|
// Handle CSV parsing errors specifically
|
||||||
|
if (error.code === 'CSV_INVALID_OPTION_VALUE' || error.code === 'CSV_PARSE_ERROR') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid CSV format. Please check your file and try again." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getUser } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// Create a new contact
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Check authentication
|
||||||
|
const user = await getUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unauthorized" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
ethereumAddress,
|
||||||
|
ensName,
|
||||||
|
email,
|
||||||
|
twitter,
|
||||||
|
discord,
|
||||||
|
telegram,
|
||||||
|
farcaster,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!ethereumAddress) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Ethereum address is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing contact with the same Ethereum address
|
||||||
|
const existingContact = await prisma.contact.findFirst({
|
||||||
|
where: {
|
||||||
|
ethereumAddress: ethereumAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingContact) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "A contact with this Ethereum address already exists" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the contact
|
||||||
|
const contact = await prisma.contact.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
ethereumAddress,
|
||||||
|
ensName,
|
||||||
|
email,
|
||||||
|
twitter,
|
||||||
|
discord,
|
||||||
|
telegram,
|
||||||
|
farcaster,
|
||||||
|
// Additional fields with defaults
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(contact, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating contact:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getUser } from "@/lib/auth";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { LogoutButton } from "@/components/auth/logout-button";
|
||||||
|
import { ImportContactsForm } from "@/components/contacts/forms/import-contacts-form";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Import Contacts - Stones Database",
|
||||||
|
description: "Import contacts to the Stones Database",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ImportContactsPage() {
|
||||||
|
const user = await getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<div className="container flex h-14 items-center justify-between">
|
||||||
|
<div className="mr-4 flex">
|
||||||
|
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||||
|
<span className="font-bold">Stones Database</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<nav className="flex items-center space-x-4">
|
||||||
|
<Link href="/contacts" className="text-sm font-medium">
|
||||||
|
Contacts
|
||||||
|
</Link>
|
||||||
|
<Link href="/dashboard" className="text-sm font-medium">
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Hello, {user.name}
|
||||||
|
</span>
|
||||||
|
<LogoutButton />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 container py-6">
|
||||||
|
<div className="flex items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<Link href="/contacts">
|
||||||
|
<button className="text-sm text-blue-500 hover:text-blue-700 mb-2">
|
||||||
|
← Back to Contacts
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold">Import Contacts</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Import from CSV</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ImportContactsForm />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
<footer className="w-full border-t py-6">
|
||||||
|
<div className="container flex flex-col items-center justify-between gap-4 md:flex-row">
|
||||||
|
<p className="text-center text-sm leading-loose text-muted-foreground md:text-left">
|
||||||
|
© 2023 Farcastle. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getUser } from "@/lib/auth";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { LogoutButton } from "@/components/auth/logout-button";
|
||||||
|
import { ContactForm } from "@/components/contacts/forms/contact-form";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Add Contact - Stones Database",
|
||||||
|
description: "Add a new contact to the Stones Database",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function NewContactPage() {
|
||||||
|
const user = await getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<div className="container flex h-14 items-center justify-between">
|
||||||
|
<div className="mr-4 flex">
|
||||||
|
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||||
|
<span className="font-bold">Stones Database</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<nav className="flex items-center space-x-4">
|
||||||
|
<Link href="/contacts" className="text-sm font-medium">
|
||||||
|
Contacts
|
||||||
|
</Link>
|
||||||
|
<Link href="/dashboard" className="text-sm font-medium">
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Hello, {user.name}
|
||||||
|
</span>
|
||||||
|
<LogoutButton />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 container py-6">
|
||||||
|
<div className="flex items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<Link href="/contacts">
|
||||||
|
<button className="text-sm text-blue-500 hover:text-blue-700 mb-2">
|
||||||
|
← Back to Contacts
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold">Add New Contact</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Contact Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ContactForm />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
<footer className="w-full border-t py-6">
|
||||||
|
<div className="container flex flex-col items-center justify-between gap-4 md:flex-row">
|
||||||
|
<p className="text-center text-sm leading-loose text-muted-foreground md:text-left">
|
||||||
|
© 2023 Farcastle. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -109,9 +109,11 @@ export default async function ContactsPage({ searchParams }: ContactsPageProps)
|
||||||
{page > 1 && <input type="hidden" name="page" value={page} />}
|
{page > 1 && <input type="hidden" name="page" value={page} />}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<Button>
|
<Link href="/contacts/new">
|
||||||
Add Contact
|
<Button>
|
||||||
</Button>
|
Add Contact
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
<Link href={`/api/contacts/export?search=${encodeURIComponent(search)}`}>
|
<Link href={`/api/contacts/export?search=${encodeURIComponent(search)}`}>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
Export Data
|
Export Data
|
||||||
|
|
|
||||||
|
|
@ -114,12 +114,16 @@ export default async function DashboardPage() {
|
||||||
<Link href="/contacts?page=1">
|
<Link href="/contacts?page=1">
|
||||||
<Button variant="outline" className="w-full">View Recent Contacts</Button>
|
<Button variant="outline" className="w-full">View Recent Contacts</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button variant="outline" className="w-full">
|
<Link href="/contacts/new">
|
||||||
Add New Contact
|
<Button variant="outline" className="w-full">
|
||||||
</Button>
|
Add New Contact
|
||||||
<Button variant="outline" className="w-full">
|
</Button>
|
||||||
Import Contacts
|
</Link>
|
||||||
</Button>
|
<Link href="/contacts/import">
|
||||||
|
<Button variant="outline" className="w-full">
|
||||||
|
Import Contacts
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
interface ContactFormProps {
|
||||||
|
defaultValues?: {
|
||||||
|
name?: string;
|
||||||
|
ethereumAddress?: string;
|
||||||
|
ensName?: string;
|
||||||
|
email?: string;
|
||||||
|
twitter?: string;
|
||||||
|
discord?: string;
|
||||||
|
telegram?: string;
|
||||||
|
farcaster?: string;
|
||||||
|
};
|
||||||
|
contactId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactForm({ defaultValues = {}, contactId }: ContactFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isEditing = !!contactId;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const data = Object.fromEntries(formData.entries());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
isEditing ? `/api/contacts/${contactId}` : "/api/contacts",
|
||||||
|
{
|
||||||
|
method: isEditing ? "PATCH" : "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || "Something went wrong");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Redirect to the new/edited contact
|
||||||
|
router.push(`/contacts/${result.id}`);
|
||||||
|
router.refresh();
|
||||||
|
} catch (error: any) {
|
||||||
|
setError(error.message || "Something went wrong. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="name" className="text-sm font-medium">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
placeholder="Enter name"
|
||||||
|
defaultValue={defaultValues.name || ""}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="ethereumAddress" className="text-sm font-medium">
|
||||||
|
Ethereum Address *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="ethereumAddress"
|
||||||
|
name="ethereumAddress"
|
||||||
|
placeholder="0x..."
|
||||||
|
defaultValue={defaultValues.ethereumAddress || ""}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="ensName" className="text-sm font-medium">
|
||||||
|
ENS Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="ensName"
|
||||||
|
name="ensName"
|
||||||
|
placeholder="example.eth"
|
||||||
|
defaultValue={defaultValues.ensName || ""}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="text-sm font-medium">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
defaultValue={defaultValues.email || ""}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="twitter" className="text-sm font-medium">
|
||||||
|
Twitter
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="twitter"
|
||||||
|
name="twitter"
|
||||||
|
placeholder="@username"
|
||||||
|
defaultValue={defaultValues.twitter || ""}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="discord" className="text-sm font-medium">
|
||||||
|
Discord
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="discord"
|
||||||
|
name="discord"
|
||||||
|
placeholder="username#0000"
|
||||||
|
defaultValue={defaultValues.discord || ""}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="telegram" className="text-sm font-medium">
|
||||||
|
Telegram
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="telegram"
|
||||||
|
name="telegram"
|
||||||
|
placeholder="@username"
|
||||||
|
defaultValue={defaultValues.telegram || ""}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="farcaster" className="text-sm font-medium">
|
||||||
|
Farcaster
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="farcaster"
|
||||||
|
name="farcaster"
|
||||||
|
placeholder="@username"
|
||||||
|
defaultValue={defaultValues.farcaster || ""}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="text-sm text-red-700">{error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? "Saving..." : isEditing ? "Update Contact" : "Create Contact"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function ImportContactsForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [importStats, setImportStats] = useState<{
|
||||||
|
total: number;
|
||||||
|
imported: number;
|
||||||
|
skipped: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
setFile(e.target.files[0]);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
setImportStats(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
setError("Please select a CSV file to import");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
setImportStats(null);
|
||||||
|
|
||||||
|
// Check if file is a CSV
|
||||||
|
if (file.type !== "text/csv" && !file.name.endsWith('.csv')) {
|
||||||
|
setError("Please upload a CSV file");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/contacts/import", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || "Failed to import contacts");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
setSuccess(`Import successful! ${result.imported} contacts imported.`);
|
||||||
|
setImportStats({
|
||||||
|
total: result.total,
|
||||||
|
imported: result.imported,
|
||||||
|
skipped: result.skipped,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
setError(error.message || "An error occurred during import");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Upload a CSV file with contact information. The CSV should include headers and at least an Ethereum Address column.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Supported columns: Name, Ethereum Address, ENS Name, Email, Twitter, Discord, Telegram, Farcaster
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="csv-file" className="text-sm font-medium">
|
||||||
|
CSV File
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="csv-file"
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="block w-full text-sm file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Max file size: 5MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{file && (
|
||||||
|
<div className="text-sm">
|
||||||
|
Selected file: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(2)} KB)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="text-sm text-red-700">{error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="rounded-md bg-green-50 p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="text-sm text-green-700">{success}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importStats && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium">Import Summary:</h3>
|
||||||
|
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
|
||||||
|
<li>Total records processed: {importStats.total}</li>
|
||||||
|
<li>Successfully imported: {importStats.imported}</li>
|
||||||
|
<li>Skipped (duplicates/errors): {importStats.skipped}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push("/contacts")}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading || !file}>
|
||||||
|
{isLoading ? "Importing..." : "Import Contacts"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{!importStats && (
|
||||||
|
<div className="border rounded-md p-4 mt-6">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Example CSV Format:</h3>
|
||||||
|
<pre className="text-xs overflow-x-auto p-2 bg-gray-50 rounded">
|
||||||
|
{"Name,Ethereum Address,ENS Name,Email,Twitter,Discord,Telegram,Farcaster\n"}
|
||||||
|
{"John Doe,0x1234567890abcdef1234567890abcdef12345678,johndoe.eth,john@example.com,@johndoe,johndoe#1234,@johndoe,@johndoe\n"}
|
||||||
|
{"Jane Smith,0xabcdef1234567890abcdef1234567890abcdef12,,jane@example.com,@janesmith,,,"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue