Skip to content

Commit c582428

Browse files
Add contact form with email functionality and form validation
1 parent e64c0ab commit c582428

File tree

6 files changed

+178
-4
lines changed

6 files changed

+178
-4
lines changed

app/api/contact/route.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { NextResponse } from 'next/server';
2+
import nodemailer from 'nodemailer';
3+
import { z } from 'zod';
4+
5+
const contactFormSchema = z.object({
6+
name: z.string().min(2, { message: "Name must be at least 2 characters" }),
7+
email: z.string().email({ message: "Please enter a valid email address" }),
8+
message: z.string().min(10, { message: "Message must be at least 10 characters" })
9+
});
10+
11+
export async function POST(request: Request) {
12+
try {
13+
// Parse and validate the request body
14+
const body = await request.json();
15+
const result = contactFormSchema.safeParse(body);
16+
17+
if (!result.success) {
18+
return NextResponse.json(
19+
{ error: "Validation failed", details: result.error.format() },
20+
{ status: 400 }
21+
);
22+
}
23+
24+
const { name, email, message } = result.data;
25+
26+
// Create a transporter
27+
const transporter = nodemailer.createTransport({
28+
service: 'gmail',
29+
auth: {
30+
user: process.env.EMAIL_USER,
31+
pass: process.env.EMAIL_APP_PASSWORD, // Use an app password for Gmail
32+
},
33+
});
34+
35+
// Email content
36+
const mailOptions = {
37+
from: process.env.EMAIL_USER,
38+
to: 'lucasbecker.dev@gmail.com',
39+
subject: `New Contact Form Submission from ${name}`,
40+
text: `
41+
Name: ${name}
42+
Email: ${email}
43+
44+
Message:
45+
${message}
46+
`,
47+
html: `
48+
<h2>New Contact Form Submission</h2>
49+
<p><strong>Name:</strong> ${name}</p>
50+
<p><strong>Email:</strong> ${email}</p>
51+
<h3>Message:</h3>
52+
<p>${message.replace(/\n/g, '<br>')}</p>
53+
`,
54+
};
55+
56+
// Send the email
57+
await transporter.sendMail(mailOptions);
58+
59+
return NextResponse.json({ success: true });
60+
} catch (error) {
61+
console.error('Error sending email:', error);
62+
return NextResponse.json(
63+
{ error: "Failed to send email" },
64+
{ status: 500 }
65+
);
66+
}
67+
}

app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import "./globals.css"
33
import type { Metadata } from "next"
44
import { Inter } from "next/font/google"
55
import { ThemeProvider } from "@/components/theme-provider"
6+
import { Toaster } from "sonner"
67

78
const inter = Inter({ subsets: ["latin"] })
89

@@ -75,6 +76,7 @@ export default function RootLayout({
7576
<body className={inter.className}>
7677
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
7778
{children}
79+
<Toaster position="top-right" />
7880
</ThemeProvider>
7981
</body>
8082
</html>

app/page.tsx

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,85 @@ import { ThemeToggle } from "@/components/theme-toggle"
1010
import { useState } from "react"
1111
import { useSmoothScroll } from "@/hooks/use-smooth-scroll"
1212
import Script from "next/script"
13+
import { z } from "zod"
14+
import { toast } from "sonner"
1315

1416
// Skill component for highlighting technologies
1517
const Skill = ({ children }: { children: React.ReactNode }) => (
1618
<strong className="text-primary">{children}</strong>
1719
);
1820

21+
// Contact form schema for validation
22+
const contactFormSchema = z.object({
23+
name: z.string().min(2, { message: "Name must be at least 2 characters" }),
24+
email: z.string().email({ message: "Please enter a valid email address" }),
25+
message: z.string().min(10, { message: "Message must be at least 10 characters" })
26+
});
27+
28+
type ContactFormValues = z.infer<typeof contactFormSchema>;
29+
1930
export default function Home() {
2031
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
2132
const handleSmoothScroll = useSmoothScroll()
33+
const [isSubmitting, setIsSubmitting] = useState(false)
34+
const [errors, setErrors] = useState<{ [key: string]: string }>({})
35+
36+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
37+
e.preventDefault();
38+
setIsSubmitting(true);
39+
setErrors({});
40+
41+
// Store a reference to the form element
42+
const form = e.currentTarget;
43+
44+
// Get form data
45+
const formData = new FormData(form);
46+
const data = {
47+
name: formData.get('name') as string,
48+
email: formData.get('email') as string,
49+
message: formData.get('message') as string
50+
};
51+
52+
// Validate form data
53+
const result = contactFormSchema.safeParse(data);
54+
55+
if (!result.success) {
56+
// Format and display validation errors
57+
const formattedErrors: { [key: string]: string } = {};
58+
result.error.issues.forEach(issue => {
59+
formattedErrors[issue.path[0] as string] = issue.message;
60+
});
61+
setErrors(formattedErrors);
62+
setIsSubmitting(false);
63+
return;
64+
}
65+
66+
try {
67+
// Send the form data to your API route
68+
const response = await fetch('/api/contact', {
69+
method: 'POST',
70+
headers: {
71+
'Content-Type': 'application/json',
72+
},
73+
body: JSON.stringify(data),
74+
});
75+
76+
const responseData = await response.json();
77+
78+
if (!response.ok) {
79+
throw new Error(responseData.error || 'Failed to send message');
80+
}
81+
82+
// Reset the form using the stored reference
83+
form.reset();
84+
toast.success('Message sent successfully! I will get back to you soon.');
85+
} catch (error) {
86+
console.error('Error submitting form:', error);
87+
toast.error('Failed to send message. Please try again later.');
88+
} finally {
89+
setIsSubmitting(false);
90+
}
91+
};
2292

2393
return (
2494
<div className="min-h-screen bg-background">
@@ -188,7 +258,7 @@ export default function Home() {
188258
</div>
189259
<div className="flex-1 flex justify-center md:justify-end">
190260
<div className="relative w-64 h-64 md:w-80 md:h-80 rounded-full overflow-hidden border-4 border-primary/20">
191-
<Image src="/placeholder.svg?height=320&width=320" alt="Profile" fill className="object-cover" priority />
261+
<Image src="pfp2.png?height=320&width=320" alt="Profile" fill className="object-cover" priority />
192262
</div>
193263
</div>
194264
</section>
@@ -547,40 +617,52 @@ export default function Home() {
547617
</div>
548618
<Card className="hover:shadow-md hover:shadow-primary/10 transition-shadow">
549619
<CardContent className="p-6">
550-
<form className="space-y-4">
620+
<form className="space-y-4" onSubmit={handleSubmit}>
551621
<div className="grid gap-2">
552622
<label htmlFor="name" className="text-sm font-medium">
553623
Name
554624
</label>
555625
<input
556626
id="name"
627+
name="name"
557628
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
558629
placeholder="Your name"
559630
/>
631+
{errors.name && (
632+
<p className="text-sm text-red-500 mt-1">{errors.name}</p>
633+
)}
560634
</div>
561635
<div className="grid gap-2">
562636
<label htmlFor="email" className="text-sm font-medium">
563637
Email
564638
</label>
565639
<input
566640
id="email"
641+
name="email"
567642
type="email"
568643
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
569644
placeholder="Your email"
570645
/>
646+
{errors.email && (
647+
<p className="text-sm text-red-500 mt-1">{errors.email}</p>
648+
)}
571649
</div>
572650
<div className="grid gap-2">
573651
<label htmlFor="message" className="text-sm font-medium">
574652
Message
575653
</label>
576654
<textarea
577655
id="message"
656+
name="message"
578657
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
579658
placeholder="Your message"
580659
/>
660+
{errors.message && (
661+
<p className="text-sm text-red-500 mt-1">{errors.message}</p>
662+
)}
581663
</div>
582-
<Button type="submit" className="w-full bg-primary text-white hover:bg-primary/90">
583-
Send Message
664+
<Button type="submit" className="w-full bg-primary text-white hover:bg-primary/90" disabled={isSubmitting}>
665+
{isSubmitting ? 'Sending...' : 'Send Message'}
584666
</Button>
585667
</form>
586668
</CardContent>

package-lock.json

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"my-v0-project": "file:",
5151
"next": "^14.2.24",
5252
"next-themes": "latest",
53+
"nodemailer": "^6.10.0",
5354
"react": "^18",
5455
"react-day-picker": "8.10.1",
5556
"react-dom": "^18",
@@ -64,6 +65,7 @@
6465
},
6566
"devDependencies": {
6667
"@types/node": "^22",
68+
"@types/nodemailer": "^6.4.17",
6769
"@types/react": "^18",
6870
"@types/react-dom": "^18",
6971
"postcss": "^8",

public/pfp2.png

852 KB
Loading

0 commit comments

Comments
 (0)