Kontaktformular: Anfragen über die Kontakt-API, damit WEBklar per Mail davon erfährt

Bisher schrieb das Formular als Gast direkt in Appwrite, und niemand las die
Einträge. Jetzt geht es an POST /api/contact (webklar-webclaw-contact-api):
die API speichert in Appwrite und meldet jede Anfrage sofort an
support@webklar.com, Antworten gehen an die Absenderadresse.

- src/lib/contact.ts ersetzt src/lib/appwrite.ts, kein Appwrite-SDK mehr im Bundle
- Honeypot-Feld "website" (unsichtbar, nicht per Tab erreichbar)
- maxLength passend zu den Grenzen der API (120/200/200/4000)
- eigener Hinweis bei 429 (zu viele Anfragen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-17 17:53:02 +00:00
1 parent 795c79d393
commit cbc149f9f8
6 files changed
+181 -63

No files matched your search

-52
View File
@@ -1,52 +0,0 @@
/**
* Appwrite-Anbindung für das Kontaktformular.
*
* In der Appwrite Console anlegen:
* 1. Database (z. B. ID: "contacts")
* 2. Collection (z. B. ID: "messages") mit String-Attributen: name, email, company, message
* 3. Unter "Settings" der Collection: Create-Berechtigung für "Any" aktivieren (öffentliches Formular)
* 4. IDs in .env setzen: VITE_APPWRITE_DATABASE_ID, VITE_APPWRITE_CONTACT_COLLECTION_ID
*/
import { Client, Databases, ID } from "appwrite";
const CONTACT_DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID ?? "698124a20035e8f6dc42";
const CONTACT_COLLECTION_ID = import.meta.env.VITE_APPWRITE_CONTACT_COLLECTION_ID ?? "contact_submissions";
function getDatabases(): Databases {
const endpoint = import.meta.env.VITE_APPWRITE_ENDPOINT;
const projectId = import.meta.env.VITE_APPWRITE_PROJECT_ID;
if (!endpoint || !projectId) {
throw new Error(
"Appwrite ist nicht konfiguriert. Bitte VITE_APPWRITE_ENDPOINT und VITE_APPWRITE_PROJECT_ID setzen. " +
"Lokal: .env anlegen (z. B. mit „npm run setup:env“) und Build/Dev-Server neu starten. " +
"Server: gleiche Variablen in der Build-Umgebung setzen (z. B. in .env vor „npm run build“)."
);
}
const client = new Client().setEndpoint(endpoint).setProject(projectId);
return new Databases(client);
}
let _databases: Databases | null = null;
export type ContactFormData = {
name: string;
email: string;
company: string;
message: string;
};
export async function createContactDocument(data: ContactFormData) {
if (!_databases) _databases = getDatabases();
return _databases.createDocument<ContactFormData>(
CONTACT_DATABASE_ID,
CONTACT_COLLECTION_ID,
ID.unique(),
{
name: data.name,
email: data.email,
company: data.company,
message: data.message,
},
[] // Keine Dokument-Permissions; nur Collection-Berechtigung „Create für Any“ wird genutzt
);
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Kontaktformular: schickt die Anfrage an die Kontakt-API von webklar.com (POST /api/contact).
*
* Die API speichert die Anfrage in Appwrite (Collection contact_submissions) und meldet sie sofort
* per Mail an support@webklar.com, Antworten gehen direkt an die Absenderadresse. Sie liegt auf dem
* Server unter apps/webklar.com/contact-api/server.js (nicht in diesem Repo).
*
* `website` ist das Honeypot-Feld: Menschen sehen es nicht, Bots füllen es aus.
*/
export type ContactFormData = {
name: string;
email: string;
company: string;
message: string;
website: string;
};
/** Feldgrenzen der Kontakt-API (liegen unter den Attributgrößen der Appwrite-Collection). */
export const CONTACT_LIMITS = { name: 120, email: 200, company: 200, message: 4000 } as const;
export class ContactSendError extends Error {
constructor(readonly status: number) {
super(`Kontakt-API antwortete mit ${status}`);
this.name = "ContactSendError";
}
/** 429: zu viele Anfragen in kurzer Zeit. */
get tooMany() {
return this.status === 429;
}
}
export async function sendContactMessage(data: ContactFormData): Promise<void> {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new ContactSendError(res.status);
}
+28 -6
View File
@@ -1,5 +1,6 @@
import { useRef, useState } from "react";
import { usePageMeta } from "@/hooks/use-page-meta";
import { CONTACT_LIMITS, ContactSendError, sendContactMessage } from "@/lib/contact";
import "./unterseiten.css";
type Rueckmeldung = { art: "erfolg" | "fehler"; titel: string; text: string };
@@ -18,6 +19,7 @@ const Contact = () => {
email: "",
company: "",
message: "",
website: "",
});
const handleChange = (
@@ -35,20 +37,21 @@ const Contact = () => {
setIsSubmitting(true);
setRueckmeldung(null);
try {
// Das Appwrite-SDK wird erst beim Absenden geladen (Spezifikation 9.7)
const { createContactDocument } = await import("@/lib/appwrite");
await createContactDocument(formData);
await sendContactMessage(formData);
setRueckmeldung({
art: "erfolg",
titel: "Nachricht gesendet.",
text: "Wir melden uns spätestens am nächsten Werktag bei Ihnen.",
});
setFormData({ name: "", email: "", company: "", message: "" });
} catch {
setFormData({ name: "", email: "", company: "", message: "", website: "" });
} catch (fehler) {
setRueckmeldung({
art: "fehler",
titel: "Die Nachricht konnte nicht gesendet werden.",
text: "Bitte versuchen Sie es noch einmal oder schreiben Sie uns direkt per E-Mail.",
text:
fehler instanceof ContactSendError && fehler.tooMany
? "Es kamen gerade zu viele Anfragen. Bitte versuchen Sie es in ein paar Minuten noch einmal oder schreiben Sie uns direkt per E-Mail."
: "Bitte versuchen Sie es noch einmal oder schreiben Sie uns direkt per E-Mail.",
});
} finally {
setIsSubmitting(false);
@@ -83,6 +86,7 @@ const Contact = () => {
type="text"
required
autoComplete="name"
maxLength={CONTACT_LIMITS.name}
value={formData.name}
onChange={handleChange}
placeholder="Ihr Name"
@@ -96,6 +100,7 @@ const Contact = () => {
type="email"
required
autoComplete="email"
maxLength={CONTACT_LIMITS.email}
value={formData.email}
onChange={handleChange}
placeholder="ihre@email.de"
@@ -112,6 +117,7 @@ const Contact = () => {
name="company"
type="text"
autoComplete="organization"
maxLength={CONTACT_LIMITS.company}
value={formData.company}
onChange={handleChange}
placeholder="Name Ihres Betriebs"
@@ -124,12 +130,28 @@ const Contact = () => {
id="message"
name="message"
required
maxLength={CONTACT_LIMITS.message}
value={formData.message}
onChange={handleChange}
placeholder="z. B. neue Website, Online-Shop, Web-App oder Automatisierung …"
/>
</div>
{/* Honeypot gegen Bots: für Menschen unsichtbar und nicht erreichbar, die Kontakt-API verwirft
Anfragen, in denen es ausgefüllt ist */}
<div className="wk-visually-hidden" aria-hidden="true">
<label htmlFor="website">Website (bitte leer lassen)</label>
<input
id="website"
name="website"
type="text"
tabIndex={-1}
autoComplete="off"
value={formData.website}
onChange={handleChange}
/>
</div>
<div className="wk-form__senden">
<button type="submit" className="wk-btn wk-btn--primary" aria-disabled={isSubmitting}>
{isSubmitting ? "Wird gesendet …" : "Nachricht senden"}
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import Contact from "@/pages/Contact";
import { sendContactMessage, ContactSendError } from "@/lib/contact";
// Das Formular wird gegen eine Attrappe getestet, die echte Funktion weiter unten über importActual.
vi.mock("@/lib/contact", async (original) => ({
...(await original<typeof import("@/lib/contact")>()),
sendContactMessage: vi.fn().mockResolvedValue(undefined),
}));
const echt = () => vi.importActual<typeof import("@/lib/contact")>("@/lib/contact");
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const zeigeKontakt = () =>
render(
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<Contact />
</MemoryRouter>,
);
describe("sendContactMessage", () => {
it("schickt die Anfrage als JSON an /api/contact", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response('{"success":true}', { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const { sendContactMessage: senden } = await echt();
const daten = { name: "Erika", email: "erika@example.de", company: "", message: "Hallo", website: "" };
await senden(daten);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, optionen] = fetchMock.mock.calls[0];
expect(url).toBe("/api/contact");
expect(optionen.method).toBe("POST");
expect(optionen.headers).toEqual({ "Content-Type": "application/json" });
expect(JSON.parse(optionen.body)).toEqual(daten);
});
it("wirft bei einer Fehlerantwort, 429 ist als »zu viele« erkennbar", async () => {
const { sendContactMessage: senden, ContactSendError: Fehler } = await echt();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 429 })));
const fehler = await senden({ name: "a", email: "a@b.de", company: "", message: "x", website: "" }).catch((e) => e);
expect(fehler).toBeInstanceOf(Fehler);
expect(fehler.tooMany).toBe(true);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 502 })));
const anderer = await senden({ name: "a", email: "a@b.de", company: "", message: "x", website: "" }).catch((e) => e);
expect(anderer.status).toBe(502);
expect(anderer.tooMany).toBe(false);
});
});
describe("Kontaktformular und Kontakt-API", () => {
it("hat ein Honeypot-Feld, das Menschen weder sehen noch per Tab erreichen", () => {
zeigeKontakt();
const falle = document.querySelector<HTMLInputElement>('input[name="website"]');
expect(falle).not.toBeNull();
expect(falle).toHaveAttribute("tabindex", "-1");
expect(falle).toHaveAttribute("autocomplete", "off");
expect(falle).not.toBeRequired();
const huelle = falle!.closest("[aria-hidden]");
expect(huelle).toHaveAttribute("aria-hidden", "true");
expect(huelle).toHaveClass("wk-visually-hidden");
});
it("begrenzt die Felder auf die Längen, die die Kontakt-API annimmt", () => {
zeigeKontakt();
expect(screen.getByLabelText(/^Name/)).toHaveAttribute("maxlength", "120");
expect(screen.getByLabelText(/^E-Mail/)).toHaveAttribute("maxlength", "200");
expect(screen.getByLabelText(/^Unternehmen/)).toHaveAttribute("maxlength", "200");
expect(screen.getByLabelText(/^Nachricht/)).toHaveAttribute("maxlength", "4000");
});
it("bei 429 erscheint ein Hinweis, es später noch einmal zu versuchen", async () => {
vi.mocked(sendContactMessage).mockRejectedValueOnce(new ContactSendError(429));
zeigeKontakt();
fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: "Erika Muster" } });
fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "erika@example.de" } });
fireEvent.change(screen.getByLabelText(/^Nachricht/), { target: { value: "Hallo" } });
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/ }));
expect(await screen.findByText(/zu viele Anfragen/)).toBeInTheDocument();
expect(screen.getByLabelText(/^Name/)).toHaveValue("Erika Muster");
});
it("im Quelltext lädt nichts mehr das Appwrite-SDK", () => {
const dateien = (ordner: string): string[] =>
readdirSync(ordner).flatMap((name) => {
const pfad = join(ordner, name);
return statSync(pfad).isDirectory() ? dateien(pfad) : [pfad];
});
const treffer = dateien(join(process.cwd(), "src"))
.filter((pfad) => /\.(ts|tsx)$/.test(pfad) && !pfad.includes(`${join("src", "test")}`))
.filter((pfad) => /from\s+["']appwrite["']|import\(\s*["']@\/lib\/appwrite["']/.test(readFileSync(pfad, "utf8")));
expect(treffer).toEqual([]);
});
});
+4 -1
View File
@@ -8,7 +8,10 @@ import { NAVIGATION } from "@/components/site/navigation";
import { projekte } from "@/data/projekte";
// Netzwerk gibt es in jsdom nicht: das Kontaktformular-Backend wird durch eine Attrappe ersetzt.
vi.mock("@/lib/appwrite", () => ({ createContactDocument: vi.fn().mockResolvedValue(undefined) }));
vi.mock("@/lib/contact", async (original) => ({
...(await original<typeof import("@/lib/contact")>()),
sendContactMessage: vi.fn().mockResolvedValue(undefined),
}));
// Dieselben Future-Flags wie der BrowserRouter in App.tsx, sonst warnt React Router in der Testausgabe.
const routerFuture = { v7_startTransition: true, v7_relativeSplatPath: true };
+8 -4
View File
@@ -17,7 +17,7 @@ import KontaktAbschnitt from "@/components/Contact";
import About from "@/pages/About";
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
import { ScrollToTop } from "@/App";
import { createContactDocument } from "@/lib/appwrite";
import { sendContactMessage, ContactSendError } from "@/lib/contact";
// WebGL, Canvas und Netzwerk laden wir in jsdom nicht: die Effekt-Komponenten und
// das Kontaktformular-Backend werden durch Attrappen ersetzt.
@@ -25,7 +25,10 @@ vi.mock("@/components/Silk", () => ({ default: () => null }));
vi.mock("@/components/PixelBlast", () => ({ default: () => null }));
vi.mock("@/components/LightRays", () => ({ default: () => null }));
vi.mock("@/components/ui/flickering-grid", () => ({ FlickeringGrid: () => null }));
vi.mock("@/lib/appwrite", () => ({ createContactDocument: vi.fn().mockResolvedValue(undefined) }));
vi.mock("@/lib/contact", async (original) => ({
...(await original<typeof import("@/lib/contact")>()),
sendContactMessage: vi.fn().mockResolvedValue(undefined),
}));
// Dieselben Future-Flags wie der BrowserRouter in App.tsx, sonst warnt React Router in der Testausgabe.
const routerFuture = { v7_startTransition: true, v7_relativeSplatPath: true };
@@ -219,18 +222,19 @@ describe("B4 Antwortzeit: spätestens am nächsten Werktag", () => {
kontaktAbsenden();
const meldung = await screen.findByText("Wir melden uns spätestens am nächsten Werktag bei Ihnen.");
expect(meldung.closest("[aria-live]")).toHaveAttribute("aria-live", "polite");
expect(createContactDocument).toHaveBeenCalledWith({
expect(sendContactMessage).toHaveBeenCalledWith({
name: "Erika Muster",
email: "erika@example.de",
company: "",
message: "Wir brauchen eine neue Website.",
website: "",
});
// nach dem Senden ist das Formular wieder leer
expect(screen.getByLabelText(/^Name/)).toHaveValue("");
});
it("Kontaktformular: bei einem Fehler bleibt die Eingabe stehen und eine verständliche Meldung erscheint", async () => {
vi.mocked(createContactDocument).mockRejectedValueOnce(new Error("Appwrite ist nicht konfiguriert."));
vi.mocked(sendContactMessage).mockRejectedValueOnce(new ContactSendError(502));
kontaktAbsenden();
const meldung = await screen.findByText(/Bitte versuchen Sie es noch einmal/);
expect(meldung.closest("[aria-live]")).toHaveAttribute("aria-live", "polite");