From 05f879d226d4b51e8fe367d750e0bdf1cce1dafe Mon Sep 17 00:00:00 2001 From: Denshooter Date: Tue, 4 Feb 2025 21:12:13 +0100 Subject: [PATCH] feat: implement email sending functionality with nodemailer; add contact form handling and success/error notifications --- app/api/email/route.tsx | 75 ++++++++++++++++++++++++++++++++++++++ app/api/stats/route.ts | 34 ++++++++--------- app/components/Contact.tsx | 41 +++++++++++++++++++-- app/globals.css | 13 +++++++ app/utils/send-email.tsx | 17 +++++++++ package-lock.json | 34 +++++++++++++++++ package.json | 7 +++- 7 files changed, 199 insertions(+), 22 deletions(-) create mode 100644 app/api/email/route.tsx create mode 100644 app/utils/send-email.tsx diff --git a/app/api/email/route.tsx b/app/api/email/route.tsx new file mode 100644 index 0000000..d2a3ea7 --- /dev/null +++ b/app/api/email/route.tsx @@ -0,0 +1,75 @@ +import {type NextRequest, NextResponse} from 'next/server'; +import nodemailer from "nodemailer"; +import SMTPTransport from "nodemailer/lib/smtp-transport"; +import Mail from "nodemailer/lib/mailer"; +import dotenv from 'dotenv'; + +dotenv.config(); + +export async function POST(request: NextRequest) { + const {email, name, message} = await request.json(); + + const user = process.env.MY_EMAIL ?? ''; + const pass = process.env.MY_PASSWORD ?? ''; + + if (!user || !pass) { + console.error('Missing email or password environment variables'); + return NextResponse.json({error: 'Internal server error'}, {status: 500}); + } + + const transportOptions: SMTPTransport.Options = { + host: "smtp.ionos.de", + port: 587, + secure: false, + requireTLS: true, + auth: { + type: 'login', + user, + pass + }, + }; + + const transport = nodemailer.createTransport(transportOptions); + + const mailOptions: Mail.Options = { + from: user, + to: user, // Ensure this is the correct email address + subject: `Message from ${name} (${email})`, + text: message + `\n\nSent from ${email}`, + }; + + const returnMail: Mail.Options = { + from: user, + to: email, + subject: `DKI - Received your message`, + text: `Hello ${name},\n\nThank you for your message. I will get back to you as soon as possible.\n\nBest regards,\nDennis Konkol`, + }; + + const sendMailPromise = () => + new Promise((resolve, reject) => { + transport.sendMail(mailOptions, function (err, info) { + if (!err) { + console.log('Email sent:', info.response); + resolve(info.response); + } else { + console.error('Error sending email:', err); + reject(err.message); + } + }); + transport.sendMail(returnMail, function (err, info) { + if (err) { + console.error('Error sending return email:', err); + } else { + console.log('Return email sent:', info.response); + } + }); + }); + + try { + await sendMailPromise(); + return NextResponse.json({message: 'Email sent'}); + } catch (err) { + console.error('Error sending email:', err); + return NextResponse.json({error: err}, {status: 500}); + } +} \ No newline at end of file diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts index cb50d5e..53c0869 100644 --- a/app/api/stats/route.ts +++ b/app/api/stats/route.ts @@ -1,29 +1,29 @@ -// app/api/stats/route.ts -import { NextResponse } from "next/server"; +// app/api/stats/route.tsx +import {NextResponse} from "next/server"; const stats = { - views: 0, - projectsViewed: {} as { [key: string]: number }, + views: 0, + projectsViewed: {} as { [key: string]: number }, }; export async function GET() { - return NextResponse.json(stats); + return NextResponse.json(stats); } export async function POST(request: Request) { - const { type, projectId } = await request.json(); + const {type, projectId} = await request.json(); - if (type === "page_view") { - stats.views += 1; - } - - if (type === "project_view" && projectId) { - if (stats.projectsViewed[projectId]) { - stats.projectsViewed[projectId] += 1; - } else { - stats.projectsViewed[projectId] = 1; + if (type === "page_view") { + stats.views += 1; } - } - return NextResponse.json({ message: "Stats updated", stats }); + if (type === "project_view" && projectId) { + if (stats.projectsViewed[projectId]) { + stats.projectsViewed[projectId] += 1; + } else { + stats.projectsViewed[projectId] = 1; + } + } + + return NextResponse.json({message: "Stats updated", stats}); } diff --git a/app/components/Contact.tsx b/app/components/Contact.tsx index 0c69d48..f221f96 100644 --- a/app/components/Contact.tsx +++ b/app/components/Contact.tsx @@ -1,8 +1,19 @@ -// app/components/Contact.tsx import React, {useEffect, useState} from "react"; +import {sendEmail} from "@/app/utils/send-email"; + +export type FormData = { + name: string; + email: string; + message: string; +} export default function Contact() { const [isVisible, setIsVisible] = useState(false); + const [banner, setBanner] = useState<{ show: boolean, message: string, type: 'success' | 'error' }>({ + show: false, + message: '', + type: 'success' + }); useEffect(() => { setTimeout(() => { @@ -10,14 +21,38 @@ export default function Contact() { }, 350); // Delay to start the animation after Projects }, []); + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const data: FormData = { + name: (form.elements.namedItem('name') as HTMLInputElement).value, + email: (form.elements.namedItem('email') as HTMLInputElement).value, + message: (form.elements.namedItem('message') as HTMLTextAreaElement).value, + }; + const response = await sendEmail(data); + if (response.success) { + form.reset(); + } + setBanner({show: true, message: response.message, type: response.success ? 'success' : 'error'}); + setTimeout(() => { + setBanner({...banner, show: false}); + }, 3000); // Hide banner after 3 seconds + } + return (

Contact Me

-
+ className="flex flex-col items-center p-8 bg-gradient-to-br from-white/60 to-white/30 backdrop-blur-lg rounded-2xl shadow-xl max-w-lg mx-auto mt-6 relative"> + {banner.show && ( +
+ {banner.message} +
+ )} + { + const apiEndpoint = '/api/email'; + + return fetch(apiEndpoint, { + method: 'POST', + body: JSON.stringify(data), + }) + .then((res) => res.json()) + .then((response) => { + return {success: true, message: response.message}; + }) + .catch((err) => { + return {success: false, message: err.message}; + }); +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4497e38..d7221ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,10 @@ "@vercel/analytics": "^1.4.1", "@vercel/og": "^0.6.5", "@vercel/speed-insights": "^1.1.0", + "dotenv": "^16.4.7", "gray-matter": "^4.0.3", "next": "15.1.3", + "nodemailer": "^6.10.0", "prisma": "^6.1.0", "react": "^19.0.0", "react-cookie-consent": "^9.0.0", @@ -25,6 +27,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@types/node": "^20", + "@types/nodemailer": "^6.4.17", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -1079,6 +1082,16 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.17", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", + "integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.0.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", @@ -2380,6 +2393,18 @@ "node": ">=0.10.0" } }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5683,6 +5708,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/nodemailer": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.0.tgz", + "integrity": "sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index 8d685a1..6434f88 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,15 @@ "dependencies": { "@prisma/client": "^6.1.0", "@vercel/analytics": "^1.4.1", - "@vercel/speed-insights": "^1.1.0", "@vercel/og": "^0.6.5", - "react-cookie-consent": "^9.0.0", + "@vercel/speed-insights": "^1.1.0", + "dotenv": "^16.4.7", "gray-matter": "^4.0.3", "next": "15.1.3", + "nodemailer": "^6.10.0", "prisma": "^6.1.0", "react": "^19.0.0", + "react-cookie-consent": "^9.0.0", "react-dom": "^19.0.0", "react-markdown": "^9.0.3", "rehype-raw": "^7.0.0", @@ -26,6 +28,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@types/node": "^20", + "@types/nodemailer": "^6.4.17", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9",