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:
parent
8d5fde5f02
commit
9c7f78464b
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
const contactItems = [
|
const contactItems = [
|
||||||
{
|
{
|
||||||
icon: (
|
icon: (
|
||||||
|
|
@ -21,7 +25,41 @@ const contactItems = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type Status = "idle" | "loading" | "success" | "error";
|
||||||
|
|
||||||
export default function Contact() {
|
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 (
|
return (
|
||||||
<section id="contact" className="py-24 px-4 sm:px-6 lg:px-8 relative">
|
<section id="contact" className="py-24 px-4 sm:px-6 lg:px-8 relative">
|
||||||
<div className="absolute inset-0 bg-[#111925]" />
|
<div className="absolute inset-0 bg-[#111925]" />
|
||||||
|
|
@ -43,14 +81,41 @@ export default function Contact() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact form */}
|
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-3xl p-8 sm:p-10">
|
<div className="bg-gray-900 border border-gray-800 rounded-3xl p-8 sm:p-10">
|
||||||
<form className="space-y-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 className="grid sm:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">Name</label>
|
<label className="block text-sm font-medium text-slate-300 mb-2">Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Max Mustermann"
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
@ -59,6 +124,9 @@ export default function Contact() {
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">E-Mail</label>
|
<label className="block text-sm font-medium text-slate-300 mb-2">E-Mail</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="max@beispiel.de"
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
@ -67,13 +135,18 @@ export default function Contact() {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">Betreff</label>
|
<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">
|
<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="">Thema auswählen...</option>
|
||||||
<option value="docker">Docker Installation</option>
|
<option value="docker">Docker Installation</option>
|
||||||
<option value="kubernetes">Kubernetes Setup</option>
|
<option value="kubernetes">Kubernetes Setup</option>
|
||||||
<option value="proxmox">Proxmox Virtualisierung</option>
|
<option value="proxmox">Proxmox Virtualisierung</option>
|
||||||
<option value="hetzner">Hetzner Cloud Infrastruktur</option>
|
<option value="hetzner">Hetzner Cloud Infrastruktur</option>
|
||||||
<option value="hardware">Hardware & Software</option>
|
<option value="hardware">Hardware & Software</option>
|
||||||
<option value="other">Sonstiges</option>
|
<option value="other">Sonstiges</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -82,15 +155,45 @@ export default function Contact() {
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">Nachricht</label>
|
<label className="block text-sm font-medium text-slate-300 mb-2">Nachricht</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={5}
|
rows={5}
|
||||||
|
value={nachricht}
|
||||||
|
onChange={(e) => setNachricht(e.target.value)}
|
||||||
placeholder="Beschreiben Sie Ihr Projekt oder Ihre Anfrage..."
|
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"
|
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>
|
||||||
|
|
||||||
<button type="submit" className="btn-primary w-full py-4 text-lg">
|
{status === "error" && (
|
||||||
Nachricht senden
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="flex items-center gap-4 my-8">
|
<div className="flex items-center gap-4 my-8">
|
||||||
|
|
|
||||||
|
|
@ -111,18 +111,19 @@ async function dbMarkRetry(id: string, retryCount: number, error: string) {
|
||||||
|
|
||||||
// ─── Queue-Eintrag speichern ───────────────────────────────────────────────
|
// ─── Queue-Eintrag speichern ───────────────────────────────────────────────
|
||||||
|
|
||||||
export async function queueEmail(mail: QueuedMail): Promise<void> {
|
export async function queueEmail(mail: QueuedMail): Promise<boolean> {
|
||||||
if (!isSupabaseConfigured()) {
|
if (!isSupabaseConfigured()) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[EmailQueue] Supabase nicht konfiguriert – Mail nicht gespeichert: "${mail.subject}" → "${mail.mail_to}"`
|
`[EmailQueue] Supabase nicht konfiguriert – Mail nicht gespeichert: "${mail.subject}" → "${mail.mail_to}"`
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
const saved = await dbInsert(mail);
|
const saved = await dbInsert(mail);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
console.log(`[EmailQueue] "${mail.subject}" → "${mail.mail_to}" in Queue gespeichert`);
|
console.log(`[EmailQueue] "${mail.subject}" → "${mail.mail_to}" in Queue gespeichert`);
|
||||||
processQueue().catch(() => {});
|
processQueue().catch(() => {});
|
||||||
}
|
}
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Worker ───────────────────────────────────────────────────────────────
|
// ─── Worker ───────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ const transporter = nodemailer.createTransport({
|
||||||
export interface KontaktEmailData {
|
export interface KontaktEmailData {
|
||||||
name: string;
|
name: string;
|
||||||
anrede?: string;
|
anrede?: string;
|
||||||
telefon: string;
|
telefon?: string;
|
||||||
email: string;
|
email: string;
|
||||||
betreff: string;
|
betreff: string;
|
||||||
nachricht?: string;
|
nachricht?: string;
|
||||||
|
|
@ -37,18 +37,22 @@ interface MailOptions {
|
||||||
html: string;
|
html: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendWithFallback(options: MailOptions, label: string) {
|
async function sendWithFallback(
|
||||||
|
options: MailOptions,
|
||||||
|
label: string
|
||||||
|
): Promise<{ sent: boolean; queued: boolean }> {
|
||||||
if (!options.to) {
|
if (!options.to) {
|
||||||
console.error(`[Mailer] Kein Empfänger für "${label}" – Mail übersprungen`);
|
console.error(`[Mailer] Kein Empfänger für "${label}" – Mail übersprungen`);
|
||||||
return;
|
return { sent: false, queued: false };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await transporter.sendMail(options);
|
await transporter.sendMail(options);
|
||||||
console.log(`[Mailer] ✓ Mail "${label}" an "${options.to}" gesendet`);
|
console.log(`[Mailer] ✓ Mail "${label}" an "${options.to}" gesendet`);
|
||||||
|
return { sent: true, queued: false };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
console.error(`[Mailer] ✗ Mail "${label}" fehlgeschlagen (${msg}) – in Queue gespeichert`);
|
console.error(`[Mailer] ✗ Mail "${label}" fehlgeschlagen (${msg}) – versuche Queue`);
|
||||||
await queueEmail({
|
const queued = await queueEmail({
|
||||||
mail_from: options.from,
|
mail_from: options.from,
|
||||||
mail_to: options.to,
|
mail_to: options.to,
|
||||||
reply_to: options.replyTo,
|
reply_to: options.replyTo,
|
||||||
|
|
@ -56,10 +60,13 @@ async function sendWithFallback(options: MailOptions, label: string) {
|
||||||
html: options.html,
|
html: options.html,
|
||||||
body_text: options.text,
|
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
|
const anrede = data.anrede
|
||||||
? `${data.anrede.charAt(0).toUpperCase() + data.anrede.slice(1)} `
|
? `${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>
|
<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">
|
<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;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">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>
|
<tr><td style="padding:6px 0;color:#94a3b8">Betreff</td><td style="padding:6px 0;color:#f8fafc">${data.betreff}</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -93,13 +100,13 @@ export async function sendeKontaktEmail(data: KontaktEmailData) {
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
await sendWithFallback(
|
return sendWithFallback(
|
||||||
{
|
{
|
||||||
from: `"MBO Tech IT" <${process.env.SMTP_FROM}>`,
|
from: `"MBO Tech IT" <${process.env.SMTP_FROM}>`,
|
||||||
to: process.env.SMTP_TO,
|
to: process.env.SMTP_TO,
|
||||||
replyTo: data.email,
|
replyTo: data.email,
|
||||||
subject: `Kontaktanfrage: ${anrede}${data.name} – ${data.betreff}`,
|
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,
|
html,
|
||||||
},
|
},
|
||||||
`Kontaktanfrage ${data.name}`
|
`Kontaktanfrage ${data.name}`
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue