diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 6916f27..4b814b0 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@getbrevo/brevo": "^5.0.4", "@workspace/api-zod": "workspace:*", "@workspace/db": "workspace:*", "cookie-parser": "^1.4.7", diff --git a/artifacts/api-server/src/routes/contact.ts b/artifacts/api-server/src/routes/contact.ts new file mode 100644 index 0000000..429aa7f --- /dev/null +++ b/artifacts/api-server/src/routes/contact.ts @@ -0,0 +1,73 @@ +import { Router, type IRouter } from "express"; +import { BrevoClient } from "@getbrevo/brevo"; +import { SendContactMessageBody } from "@workspace/api-zod"; + +const router: IRouter = Router(); + +function getBrevoClient() { + const apiKey = process.env.BREVO_API_KEY; + if (!apiKey) throw new Error("BREVO_API_KEY is not set"); + return new BrevoClient({ apiKey }); +} + +router.post("/contact", async (req, res) => { + const parsed = SendContactMessageBody.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ + success: false, + message: "Ungültige Eingabe. Bitte überprüfen Sie Ihre Angaben.", + }); + return; + } + + const { name, email, subject, message } = parsed.data; + + try { + const brevo = getBrevoClient(); + + await brevo.transactionalEmails.sendTransacEmail({ + sender: { name: "Kontaktformular Portfolio", email: "jh@unixweb.de" }, + to: [{ email: "jh@unixweb.de", name: "Joachim Hummel" }], + replyTo: { email, name }, + subject: subject ? `[Portfolio] ${subject}` : `[Portfolio] Neue Anfrage von ${name}`, + textContent: [ + `Name: ${name}`, + `E-Mail: ${email}`, + subject ? `Betreff: ${subject}` : "", + "", + message, + ] + .filter((l) => l !== undefined) + .join("\n"), + htmlContent: ` +

Name: ${escapeHtml(name)}

+

E-Mail: ${escapeHtml(email)}

+ ${subject ? `

Betreff: ${escapeHtml(subject)}

` : ""} +
+

${escapeHtml(message)}

+ `, + }); + + req.log.info({ to: "jh@unixweb.de", from: email }, "Contact message sent via Brevo"); + res.json({ success: true, message: "Ihre Nachricht wurde erfolgreich gesendet." }); + } catch (err) { + req.log.error({ err }, "Failed to send contact email via Brevo"); + res.status(500).json({ + success: false, + message: + "Die Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.", + }); + } +}); + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 5a1f77a..5e99253 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -1,8 +1,10 @@ import { Router, type IRouter } from "express"; import healthRouter from "./health"; +import contactRouter from "./contact"; const router: IRouter = Router(); router.use(healthRouter); +router.use(contactRouter); export default router; diff --git a/artifacts/joachim-portfolio/public/opengraph.jpg b/artifacts/joachim-portfolio/public/opengraph.jpg index f8cc78a..2b0df9d 100644 Binary files a/artifacts/joachim-portfolio/public/opengraph.jpg and b/artifacts/joachim-portfolio/public/opengraph.jpg differ diff --git a/artifacts/joachim-portfolio/src/components/contact.tsx b/artifacts/joachim-portfolio/src/components/contact.tsx index a5359e7..5daa667 100644 --- a/artifacts/joachim-portfolio/src/components/contact.tsx +++ b/artifacts/joachim-portfolio/src/components/contact.tsx @@ -1,10 +1,10 @@ import { motion } from "framer-motion"; -import { Mail, ExternalLink, ArrowRight } from "lucide-react"; - -const emailAddresses = [ - { address: "jh@unixweb.de", label: "jh@unixweb.de" }, - { address: "kontakt@joachimhummel.de", label: "kontakt@joachimhummel.de" }, -]; +import { ExternalLink, ArrowRight, Send, CheckCircle, AlertCircle, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useSendContactMessage } from "@workspace/api-client-react"; const links = [ { @@ -19,6 +19,177 @@ const links = [ }, ]; +const contactSchema = z.object({ + name: z.string().min(1, "Bitte geben Sie Ihren Namen ein").max(100), + email: z.string().email("Bitte geben Sie eine gültige E-Mail-Adresse ein").max(200), + subject: z.string().max(200).optional(), + message: z.string().min(1, "Bitte geben Sie eine Nachricht ein").max(5000, "Die Nachricht ist zu lang (max. 5000 Zeichen)"), +}); + +type ContactFormData = z.infer; + +function ContactForm() { + const [status, setStatus] = useState<"idle" | "success" | "error">("idle"); + const [serverMessage, setServerMessage] = useState(""); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(contactSchema), + }); + + const { mutate: sendMessage, isPending } = useSendContactMessage({ + mutation: { + onSuccess: (data) => { + setServerMessage(data.message); + setStatus("success"); + reset(); + }, + onError: (err: unknown) => { + const apiErr = err as { data?: { message?: string } }; + setServerMessage( + apiErr?.data?.message ?? + "Die Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.", + ); + setStatus("error"); + }, + }, + }); + + const onSubmit = (data: ContactFormData) => { + setStatus("idle"); + sendMessage({ data: { name: data.name, email: data.email, subject: data.subject, message: data.message } }); + }; + + if (status === "success") { + return ( + + +
+

Nachricht gesendet!

+

{serverMessage}

+
+ +
+ ); + } + + return ( +
+
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+
+ +
+ + +
+ +
+ +