feat: add contact form submission with send confirmation

API route /api/contact calls sendeKontaktEmail and returns ok/error.
On total failure (no SMTP, no queue) logs full contact data to server log.
Contact.tsx is now a client component: shows spinner while sending,
success state ("Anfrage gesendet") on ok, error banner with message on failure.
queueEmail now returns boolean; sendWithFallback returns { sent, queued }.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MBO-Tech-IT 2026-04-26 15:23:30 +02:00
parent 8d5fde5f02
commit 9c7f78464b
4 changed files with 197 additions and 53 deletions

33
app/api/contact/route.ts Normal file
View File

@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { sendeKontaktEmail } from "@/lib/mailer";
export async function POST(request: Request) {
let body: Record<string, string>;
try {
body = await request.json();
} catch {
return NextResponse.json({ ok: false, error: "Ungültige Anfrage" }, { status: 400 });
}
const { name, email, betreff, nachricht, telefon } = body;
if (!name?.trim() || !email?.trim() || !betreff?.trim()) {
return NextResponse.json({ ok: false, error: "Pflichtfelder fehlen" }, { status: 400 });
}
const result = await sendeKontaktEmail({ name, email, betreff, nachricht, telefon });
if (!result.sent && !result.queued) {
console.error(
`[Contact] UNZUSTELLBAR Anfrage konnte weder gesendet noch in Queue gespeichert werden:\n` +
` Zeit: ${new Date().toISOString()}\n` +
` Name: ${name}\n` +
` E-Mail: ${email}\n` +
` Telefon: ${telefon ?? "(nicht angegeben)"}\n` +
` Betreff: ${betreff}\n` +
` Nachricht: ${nachricht ?? "(keine)"}`
);
return NextResponse.json({ ok: false, error: "Mail konnte nicht gesendet werden" }, { status: 500 });
}
return NextResponse.json({ ok: true, queued: result.queued });
}

View File

@ -1,3 +1,7 @@
"use client";
import { useState } from "react";
const contactItems = [
{
icon: (
@ -21,7 +25,41 @@ const contactItems = [
},
];
type Status = "idle" | "loading" | "success" | "error";
export default function Contact() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [betreff, setBetreff] = useState("");
const [nachricht, setNachricht] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [errorMsg, setErrorMsg] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setStatus("loading");
setErrorMsg("");
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, betreff, nachricht }),
});
const data = await res.json();
if (!res.ok || !data.ok) {
setErrorMsg(data.error ?? "Unbekannter Fehler");
setStatus("error");
} else {
setStatus("success");
}
} catch {
setErrorMsg("Netzwerkfehler bitte versuchen Sie es erneut.");
setStatus("error");
}
}
return (
<section id="contact" className="py-24 px-4 sm:px-6 lg:px-8 relative">
<div className="absolute inset-0 bg-[#111925]" />
@ -43,54 +81,119 @@ export default function Contact() {
</p>
</div>
{/* Contact form */}
<div className="bg-gray-900 border border-gray-800 rounded-3xl p-8 sm:p-10">
<form className="space-y-6">
<div className="grid sm:grid-cols-2 gap-6">
{status === "success" ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="w-16 h-16 rounded-full bg-orange-500/10 border border-orange-500/30 flex items-center justify-center mb-6">
<svg className="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-2xl font-bold text-white mb-2">Anfrage gesendet!</h3>
<p className="text-slate-400 mb-8">
Wir haben Ihre Nachricht erhalten und melden uns schnellstmöglich bei Ihnen.
</p>
<button
onClick={() => {
setStatus("idle");
setName("");
setEmail("");
setBetreff("");
setNachricht("");
}}
className="btn-secondary px-6 py-2"
>
Neue Anfrage
</button>
</div>
) : (
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="grid sm:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Max Mustermann"
className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">E-Mail</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="max@beispiel.de"
className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Name</label>
<input
type="text"
placeholder="Max Mustermann"
className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors"
<label className="block text-sm font-medium text-slate-300 mb-2">Betreff</label>
<select
required
value={betreff}
onChange={(e) => setBetreff(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors appearance-none"
>
<option value="">Thema auswählen...</option>
<option value="docker">Docker Installation</option>
<option value="kubernetes">Kubernetes Setup</option>
<option value="proxmox">Proxmox Virtualisierung</option>
<option value="hetzner">Hetzner Cloud Infrastruktur</option>
<option value="hardware">Hardware &amp; Software</option>
<option value="other">Sonstiges</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Nachricht</label>
<textarea
rows={5}
value={nachricht}
onChange={(e) => setNachricht(e.target.value)}
placeholder="Beschreiben Sie Ihr Projekt oder Ihre Anfrage..."
className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">E-Mail</label>
<input
type="email"
placeholder="max@beispiel.de"
className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Betreff</label>
<select className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors appearance-none">
<option value="">Thema auswählen...</option>
<option value="docker">Docker Installation</option>
<option value="kubernetes">Kubernetes Setup</option>
<option value="proxmox">Proxmox Virtualisierung</option>
<option value="hetzner">Hetzner Cloud Infrastruktur</option>
<option value="hardware">Hardware & Software</option>
<option value="other">Sonstiges</option>
</select>
</div>
{status === "error" && (
<div className="flex items-start gap-3 px-4 py-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
<svg className="w-5 h-5 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es erneut oder
kontaktieren Sie uns direkt.
{errorMsg && <span className="block mt-1 text-red-400/70 text-xs">{errorMsg}</span>}
</span>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Nachricht</label>
<textarea
rows={5}
placeholder="Beschreiben Sie Ihr Projekt oder Ihre Anfrage..."
className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors resize-none"
/>
</div>
<button type="submit" className="btn-primary w-full py-4 text-lg">
Nachricht senden
</button>
</form>
<button
type="submit"
disabled={status === "loading"}
className="btn-primary w-full py-4 text-lg disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{status === "loading" ? (
<>
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
</svg>
Wird gesendet...
</>
) : (
"Nachricht senden"
)}
</button>
</form>
)}
{/* Divider */}
<div className="flex items-center gap-4 my-8">

View File

@ -111,18 +111,19 @@ async function dbMarkRetry(id: string, retryCount: number, error: string) {
// ─── Queue-Eintrag speichern ───────────────────────────────────────────────
export async function queueEmail(mail: QueuedMail): Promise<void> {
export async function queueEmail(mail: QueuedMail): Promise<boolean> {
if (!isSupabaseConfigured()) {
console.warn(
`[EmailQueue] Supabase nicht konfiguriert Mail nicht gespeichert: "${mail.subject}" → "${mail.mail_to}"`
);
return;
return false;
}
const saved = await dbInsert(mail);
if (saved) {
console.log(`[EmailQueue] "${mail.subject}" → "${mail.mail_to}" in Queue gespeichert`);
processQueue().catch(() => {});
}
return saved;
}
// ─── Worker ───────────────────────────────────────────────────────────────

View File

@ -22,7 +22,7 @@ const transporter = nodemailer.createTransport({
export interface KontaktEmailData {
name: string;
anrede?: string;
telefon: string;
telefon?: string;
email: string;
betreff: string;
nachricht?: string;
@ -37,18 +37,22 @@ interface MailOptions {
html: string;
}
async function sendWithFallback(options: MailOptions, label: string) {
async function sendWithFallback(
options: MailOptions,
label: string
): Promise<{ sent: boolean; queued: boolean }> {
if (!options.to) {
console.error(`[Mailer] Kein Empfänger für "${label}" Mail übersprungen`);
return;
return { sent: false, queued: false };
}
try {
await transporter.sendMail(options);
console.log(`[Mailer] ✓ Mail "${label}" an "${options.to}" gesendet`);
return { sent: true, queued: false };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[Mailer] ✗ Mail "${label}" fehlgeschlagen (${msg}) in Queue gespeichert`);
await queueEmail({
console.error(`[Mailer] ✗ Mail "${label}" fehlgeschlagen (${msg}) versuche Queue`);
const queued = await queueEmail({
mail_from: options.from,
mail_to: options.to,
reply_to: options.replyTo,
@ -56,10 +60,13 @@ async function sendWithFallback(options: MailOptions, label: string) {
html: options.html,
body_text: options.text,
});
return { sent: false, queued };
}
}
export async function sendeKontaktEmail(data: KontaktEmailData) {
export async function sendeKontaktEmail(
data: KontaktEmailData
): Promise<{ sent: boolean; queued: boolean }> {
const anrede = data.anrede
? `${data.anrede.charAt(0).toUpperCase() + data.anrede.slice(1)} `
: "";
@ -77,7 +84,7 @@ export async function sendeKontaktEmail(data: KontaktEmailData) {
<h2 style="margin:0 0 16px;font-size:18px;color:#f8fafc">Kontaktanfrage von ${anrede}${data.name}</h2>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px">
<tr><td style="padding:6px 0;color:#94a3b8;width:120px">Name</td><td style="padding:6px 0;font-weight:600;color:#f8fafc">${anrede}${data.name}</td></tr>
<tr><td style="padding:6px 0;color:#94a3b8">Telefon</td><td style="padding:6px 0"><a href="tel:${data.telefon}" style="color:#f97316">${data.telefon}</a></td></tr>
${data.telefon ? `<tr><td style="padding:6px 0;color:#94a3b8">Telefon</td><td style="padding:6px 0"><a href="tel:${data.telefon}" style="color:#f97316">${data.telefon}</a></td></tr>` : ""}
<tr><td style="padding:6px 0;color:#94a3b8">E-Mail</td><td style="padding:6px 0"><a href="mailto:${data.email}" style="color:#60a5fa">${data.email}</a></td></tr>
<tr><td style="padding:6px 0;color:#94a3b8">Betreff</td><td style="padding:6px 0;color:#f8fafc">${data.betreff}</td></tr>
</table>
@ -93,13 +100,13 @@ export async function sendeKontaktEmail(data: KontaktEmailData) {
</body>
</html>`;
await sendWithFallback(
return sendWithFallback(
{
from: `"MBO Tech IT" <${process.env.SMTP_FROM}>`,
to: process.env.SMTP_TO,
replyTo: data.email,
subject: `Kontaktanfrage: ${anrede}${data.name} ${data.betreff}`,
text: `Neue Kontaktanfrage\n\nName: ${anrede}${data.name}\nTelefon: ${data.telefon}\nE-Mail: ${data.email}\nBetreff: ${data.betreff}${data.nachricht ? `\n\nNachricht:\n${data.nachricht}` : ""}`,
text: `Neue Kontaktanfrage\n\nName: ${anrede}${data.name}${data.telefon ? `\nTelefon: ${data.telefon}` : ""}\nE-Mail: ${data.email}\nBetreff: ${data.betreff}${data.nachricht ? `\n\nNachricht:\n${data.nachricht}` : ""}`,
html,
},
`Kontaktanfrage ${data.name}`