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:
1 parent
f69b34a28b
commit
c802a8c14e
7 files changed
+156
-56
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user