feat: Kontaktformular für ein Erstgespräch
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 4m31s

Neuer Abschnitt nach den Fragen, im Menü als Erstgespräch. Anfrage geht als E-Mail an die Veranstalter-Adresse (Admin) bzw. ADMIN_NOTIFY_EMAIL, sofort zugestellt, sonst über den Worker wiederholt. Gleicher Schutz wie die Buchung: nur JSON von der eigenen Seite, Rate Limit (5/h je IP), Prüfung auf dem Server, Honeypot gegen Bots, doppeltes Absenden ergibt eine Mail. Keine automatische Mail an die angegebene Adresse (sonst Missbrauch für Spam an Dritte).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-06 11:37:27 +02:00
1 parent d45700a9ef
commit 7745d5862d
9 files changed
+336 -1

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
"use client";
import { useRef, useState, type FormEvent } from "react";
type Status = { kind: "idle" } | { kind: "sending" } | { kind: "sent" } | { kind: "error"; message: string };
type Fields = Record<string, string>;
const FIELD_IDS = ["name", "email", "phone", "availability", "message", "privacy"] as const;
/**
* Anfrage fuer ein unverbindliches Erstgespraech (POST /api/contact). Die
* Pruefung passiert auf dem Server; Fehler erscheinen am jeweiligen Feld,
* der Fokus springt auf das erste fehlerhafte. Das Feld "website" ist eine
* fuer Menschen unsichtbare Falle fuer Bots.
*/
export function ContactSection() {
const formRef = useRef<HTMLFormElement>(null);
const [status, setStatus] = useState<Status>({ kind: "idle" });
const [errors, setErrors] = useState<Fields>({});
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
const text = (key: string) => String(form.get(key) ?? "");
setStatus({ kind: "sending" });
setErrors({});
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: text("name"),
email: text("email"),
phone: text("phone"),
availability: text("availability"),
message: text("message"),
privacy: form.get("privacy") === "on",
website: text("website"),
}),
});
const result = (await response.json().catch(() => null)) as { success: boolean; error?: { message: string; fields?: Fields } } | null;
if (response.ok && result?.success) {
setStatus({ kind: "sent" });
formRef.current?.reset();
return;
}
const fields = result?.error?.fields ?? {};
setErrors(fields);
setStatus({ kind: "error", message: result?.error?.message ?? "Senden hat nicht geklappt. Bitte versuche es erneut." });
const first = FIELD_IDS.find((id) => fields[id]);
if (first) requestAnimationFrame(() => document.getElementById(`ct-${first}`)?.focus());
} catch {
setStatus({ kind: "error", message: "Keine Verbindung. Bitte prüfe dein Internet und versuche es erneut." });
}
}
const fieldProps = (id: (typeof FIELD_IDS)[number]) => ({
id: `ct-${id}`,
name: id,
"aria-invalid": errors[id] ? true : undefined,
"aria-describedby": errors[id] ? `ct-${id}-error` : undefined,
});
const error = (id: string) =>
errors[id] ? (
<p id={`ct-${id}-error`} className="mt-1.5 text-sm font-medium text-rosso">
{errors[id]}
</p>
) : null;
return (
<section id="erstgespraech" aria-labelledby="erstgespraech-title" className="bg-plaster py-24 md:py-32">
<div className="container-edge grid gap-12 md:grid-cols-12">
<div className="md:col-span-5">
<h2 id="erstgespraech-title" className="section-title">
Erstgespräch
</h2>
<p className="mt-6 max-w-md text-lg text-ink/80">
Du bist dir noch unsicher, ob das Modelcamp zu dir passt? Lass uns unverbindlich sprechen. Schreib uns kurz, wann du erreichbar bist, wir melden uns bei dir.
</p>
</div>
<div className="md:col-span-7">
{status.kind === "sent" ? (
<div role="status" className="rounded-2xl bg-paper p-8 shadow-[0_1px_0_var(--color-line)]">
<p className="text-xl font-bold">Danke, deine Anfrage ist angekommen.</p>
<p className="mt-2 text-ink/80">Wir melden uns in den nächsten Tagen bei dir.</p>
<button type="button" onClick={() => setStatus({ kind: "idle" })} className="btn btn-quiet mt-6">
Weitere Anfrage senden
</button>
</div>
) : (
<form ref={formRef} onSubmit={submit} noValidate className="relative grid gap-5 rounded-2xl bg-paper p-6 shadow-[0_1px_0_var(--color-line)] md:grid-cols-2 md:p-8">
<div>
<label className="field-label" htmlFor="ct-name">Name</label>
<input {...fieldProps("name")} className="field" autoComplete="name" required maxLength={120} />
{error("name")}
</div>
<div>
<label className="field-label" htmlFor="ct-email">E-Mail</label>
<input {...fieldProps("email")} className="field" type="email" autoComplete="email" inputMode="email" required maxLength={200} />
{error("email")}
</div>
<div>
<label className="field-label" htmlFor="ct-phone">Telefon (optional)</label>
<input {...fieldProps("phone")} className="field" type="tel" autoComplete="tel" maxLength={60} />
{error("phone")}
</div>
<div>
<label className="field-label" htmlFor="ct-availability">Wann bist du erreichbar? (optional)</label>
<input {...fieldProps("availability")} className="field" placeholder="z. B. werktags ab 18 Uhr" maxLength={200} />
{error("availability")}
</div>
<div className="md:col-span-2">
<label className="field-label" htmlFor="ct-message">Worüber möchtest du sprechen? (optional)</label>
<textarea {...fieldProps("message")} className="field min-h-32" rows={4} maxLength={2000} />
{error("message")}
</div>
{/* Falle fuer Bots: nicht sichtbar, nicht per Tab erreichbar. */}
<div aria-hidden className="absolute -left-[9999px] h-px w-px overflow-hidden">
<label htmlFor="ct-website">Webseite</label>
<input id="ct-website" name="website" tabIndex={-1} autoComplete="off" />
</div>
<div className="md:col-span-2">
<label htmlFor="ct-privacy" className="flex cursor-pointer gap-3 text-[0.9375rem] leading-snug">
<input {...fieldProps("privacy")} type="checkbox" required className="mt-0.5 h-5 w-5 shrink-0 accent-ink" />
<span>
Ich bin einverstanden, dass meine Angaben zur Bearbeitung der Anfrage verwendet werden (
<a href="/datenschutz" target="_blank" rel="noopener" className="underline">
Datenschutzhinweise
</a>
).
</span>
</label>
{error("privacy")}
</div>
{status.kind === "error" && (
<p role="alert" className="rounded-lg border border-rosso/40 bg-plaster p-4 text-[0.9375rem] font-medium text-rosso md:col-span-2">
{status.message}
</p>
)}
<div className="md:col-span-2">
<button type="submit" disabled={status.kind === "sending"} className="btn btn-primary px-8 disabled:opacity-60">
{status.kind === "sending" ? "Wird gesendet …" : "Erstgespräch anfragen"}
</button>
</div>
</form>
)}
</div>
</div>
</section>
);
}