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:
1 parent
795c79d393
commit
cbc149f9f8
6 files changed
+181
-63
No files matched your search
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user