Kontaktformular verschickt wirklich

Abschnitt 06 uebergab die Eingaben bisher dem Mailprogramm der Besucherin.
Jetzt geht der Inhalt per fetch an /api/contact beim Hoster; von dort stellt
der Dienst site-mail die Nachricht ueber das Postfach info@anelia-wolf.com
(Strato) zu - kein Formulardienst eines Dritten. Faellt der Endpunkt aus,
bietet das Formular weiterhin den mailto-Weg an, damit keine Anfrage verloren
geht. Ein unsichtbares Honeypot-Feld haelt automatisierte Sendungen ab.

Die Datenschutzerklaerung zieht mit: neue Ziffer 5 (Kontaktformular) in beiden
Sprachen, Ziffer 2 nennt jetzt drei statt zwei Verarbeitungsfaelle, die
Folgeziffern sind neu durchnummeriert. README und HANDOVER beschreiben den
Weg ebenfalls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
webklarandClaude Opus 5 committed 2026-09-07 18:48:17 +00:00
1 parent f69b34a28b
commit c802a8c14e
7 files changed
+156 -56

No files matched your search

+91 -30
View File
@@ -10,18 +10,24 @@ import { TextLink } from "@/components/ui/TextLink";
/**
* Kontaktformular der Startseite (Sektion 06).
*
* **Es gibt keinen Server.** Die Website ist ein statischer Export und sagt in der
* Datenschutzerklärung zu, dass sie keine Drittanbieter einbindet und keine Daten
* überträgt. Ein Formular, das irgendwohin „postet“, würde dieses Versprechen brechen
* (und einen Auftragsverarbeitungsvertrag nötig machen). Deshalb baut das Formular aus
* den Eingaben eine fertige E-Mail und übergibt sie dem Mailprogramm der Besucherin.
* Die Daten verlassen den Rechner erst, wenn sie dort auf „Senden“ drückt.
* Die Website ist ein statischer Export, hat also selbst keinen Server. Das
* Formular schickt seine Eingaben deshalb an `/api/contact` – einen kleinen
* Dienst beim Hoster, der die Nachricht über das Postfach von Anelia Wolf
* (Strato) zustellt. Es ist kein Formulardienst eines Dritten im Spiel: die
* Daten gehen an den Hoster (Auftragsverarbeiter) und von dort ins eigene
* Postfach. Genau so steht es in `src/content/legal.ts`, Ziffern 2 und 4 –
* ändert sich der Weg, muss der Text mitwandern.
*
* Soll später doch ein echtes Formular her (Eingaben landen direkt im Postfach, ohne
* Mailprogramm), braucht es einen Endpunkt beim Hoster oder einen Formulardienst. Dann
* sind zwei Dinge Pflicht: ein Auftragsverarbeitungsvertrag und ein neuer Absatz in
* `src/content/legal.ts`. Der Umbau beträfe nur `submit()` hier in dieser Datei.
* Klappt der Versand nicht (Dienst offline, kein Netz), bleibt die Nachricht im
* Formular stehen und es erscheint ein Link, der sie ins eigene Mailprogramm
* übergibt. Eine Anfrage soll nicht daran scheitern, dass ein Server hustet.
*/
/** Endpunkt beim Hoster – gleiche Domain, deshalb ohne CORS-Vorspiel. */
const ENDPOINT = "/api/contact";
type Status = "idle" | "sending" | "sent" | "error";
export function ContactForm({
className,
content: form = contactDe.form,
@@ -34,34 +40,68 @@ export function ContactForm({
content?: HomeContent["contact"]["form"];
}) {
const uid = useId();
const [sent, setSent] = useState(false);
const [status, setStatus] = useState<Status>("idle");
const [mailtoHref, setMailtoHref] = useState("");
const field = (name: string) => `${uid}-${name}`;
const submit = (event: FormEvent<HTMLFormElement>) => {
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
if (status === "sending") return;
const formEl = event.currentTarget;
const data = new FormData(formEl);
const value = (key: string) => String(data.get(key) ?? "").trim();
const body = [
`${form.fields.name}: ${value("name")}`,
`${form.fields.email}: ${value("email")}`,
value("company") ? `${form.fields.company}: ${value("company")}` : null,
"",
value("message"),
]
.filter((line) => line !== null)
.join("\r\n");
const payload = {
name: value("name"),
email: value("email"),
company: value("company"),
message: value("message"),
page: typeof window === "undefined" ? "" : window.location.pathname,
// Honeypot: Menschen sehen das Feld nicht, Bots füllen es aus.
website: value("website"),
};
const url = `mailto:${site.email}?subject=${encodeURIComponent(form.subject)}&body=${encodeURIComponent(body)}`;
setSent(true);
window.location.href = url;
setStatus("sending");
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
formEl.reset();
setStatus("sent");
} catch {
// Rückfallweg: dieselbe Nachricht ans eigene Mailprogramm übergeben.
const body = [
`${form.fields.name}: ${payload.name}`,
`${form.fields.email}: ${payload.email}`,
payload.company ? `${form.fields.company}: ${payload.company}` : null,
"",
payload.message,
]
.filter((line) => line !== null)
.join("\r\n");
setMailtoHref(
`mailto:${site.email}?subject=${encodeURIComponent(form.subject)}&body=${encodeURIComponent(body)}`,
);
setStatus("error");
}
};
const label = "mb-2 block font-mono text-[11px] uppercase tracking-[0.16em] text-fg-muted";
const input =
"w-full border-0 border-b border-line bg-transparent pb-3 font-sans text-[1.0625rem] text-fg outline-none transition-colors duration-300 placeholder:text-transparent focus:border-accent";
const note = {
idle: form.note,
sending: form.sending,
sent: form.sent,
error: form.error,
}[status];
return (
<form onSubmit={submit} noValidate={false} className={cn("flex flex-col gap-8", className)}>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
@@ -93,13 +133,34 @@ export function ContactForm({
<textarea id={field("message")} name="message" required rows={5} className={cn(input, "resize-y")} />
</div>
{/* Honeypot – für Menschen unsichtbar und nicht fokussierbar, deshalb
aria-hidden und tabIndex -1. Ausgefüllt = automatisierter Versand. */}
<div className="absolute left-[-9999px] top-auto h-px w-px overflow-hidden" aria-hidden="true">
<label htmlFor={field("website")}>Website</label>
<input id={field("website")} name="website" type="text" tabIndex={-1} autoComplete="off" />
</div>
<div className="flex flex-col gap-4">
<button type="submit" className={cn(BUTTON_BASE, BUTTON_PRIMARY, "self-start")} aria-describedby={field("note")}>
<span className="relative z-10">{form.submit}</span>
<button
type="submit"
disabled={status === "sending"}
className={cn(BUTTON_BASE, BUTTON_PRIMARY, "self-start disabled:opacity-60")}
aria-describedby={field("note")}
>
<span className="relative z-10">{status === "sending" ? form.submitBusy : form.submit}</span>
</button>
<p id={field("note")} className="max-w-[52ch] font-mono text-[11px] leading-relaxed tracking-[0.04em] text-fg-muted">
{sent ? form.opened : form.note}{" "}
<TextLink href={privacyHref} label={form.privacy} className="!text-[11px] !tracking-[0.1em]" />
<p
id={field("note")}
role={status === "sent" || status === "error" ? "status" : undefined}
aria-live="polite"
className="max-w-[52ch] font-mono text-[11px] leading-relaxed tracking-[0.04em] text-fg-muted"
>
{note}{" "}
{status === "error" && mailtoHref ? (
<TextLink href={mailtoHref} label={form.errorLink} className="!text-[11px] !tracking-[0.1em]" />
) : (
<TextLink href={privacyHref} label={form.privacy} className="!text-[11px] !tracking-[0.1em]" />
)}
</p>
</div>
</form>