diff --git a/README.md b/README.md index 188ab8c..37c8612 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,10 @@ Diese Regeln sind nicht kosmetisch – die Datenschutzerklärung sagt sie zu: `next/font` zur Build-Zeit selbst gehostet. - **kein Storage**: kein localStorage, kein sessionStorage, keine Cookies – deshalb auch kein Cookie-Banner. -- **kein Kontaktformular**, kein Newsletter. Kontakt läuft über `mailto:` und Telefon. +- **Kontaktformular mit Serverversand**, kein Newsletter. Abschnitt 06 sendet an + `/api/contact` beim Hoster, der die Nachricht über das Postfach + `info@anelia-wolf.com` zustellt (kein Formulardienst eines Dritten). Alle + übrigen Kontaktwege laufen weiter über `mailto:` und Telefon. Wer eine dieser Zusagen ändert, muss `src/content/legal.ts` (Ziffer 2, 3 und 6) mit ändern. diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 3312a5e..abf5004 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -50,9 +50,12 @@ Datenschutzerklärung sagt sie ausdrücklich zu: (`out/_next/static/media/*.woff2`). - **kein Storage**: kein localStorage, kein sessionStorage, keine Cookies. Deshalb ist auch kein Cookie-Banner nötig und keiner eingebaut. -- **kein Server hinter dem Kontaktformular**, kein Newsletter. Das Formular in - Abschnitt 06 setzt aus den Eingaben eine `mailto:`-Nachricht zusammen und - übergibt sie dem E-Mail-Programm der Besucherin – es sendet selbst nichts. +- **Kontaktformular mit Serverversand**, kein Newsletter. Das Formular in + Abschnitt 06 schickt seine Eingaben per `fetch` an `/api/contact`. Diesen + Endpunkt bedient der Hoster (Dienst `site-mail`); er stellt die Nachricht per + SMTP über das Postfach `info@anelia-wolf.com` zu und legt nichts ab. Ist der + Dienst nicht erreichbar, bietet das Formular den alten `mailto:`-Weg an. + Beschrieben in Ziffer 5 der Datenschutzerklärung. > Wer eine dieser Zusagen ändert (z. B. ein Analytics-Tool einbaut), **muss** > `src/content/legal.ts` (Ziffer 2, 3 und 6 der Datenschutzerklärung) mit ändern diff --git a/src/components/ui/ContactForm.tsx b/src/components/ui/ContactForm.tsx index 53b63da..29e5966 100644 --- a/src/components/ui/ContactForm.tsx +++ b/src/components/ui/ContactForm.tsx @@ -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("idle"); + const [mailtoHref, setMailtoHref] = useState(""); const field = (name: string) => `${uid}-${name}`; - const submit = (event: FormEvent) => { + const submit = async (event: FormEvent) => { 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 (
@@ -93,13 +133,34 @@ export function ContactForm({