Compare commits
4
Commits
8f5d522682
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3df23fabaf | ||
|
|
09e1832041 | ||
|
|
cbc149f9f8 | ||
|
|
795c79d393 |
No files matched your search
@@ -51,6 +51,8 @@
|
||||
content="WEBklar baut Websites, Online-Shops und Web-Apps. Im Kundenportal sehen Sie jeden Schritt, mit Live-Vorschau Ihrer Website."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://webklar.com/og-image.png" />
|
||||
|
||||
<script data-wk-analytics src="https://project.webklar.com/a/w.js?s=yiy992rvq7vchfd7bqtqivah" data-site="yiy992rvq7vchfd7bqtqivah" data-color="#1d1d1f" data-variant="eigen" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Datenschutz bei der Reichweitenmessung – WEBklar</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 12rem; }
|
||||
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
|
||||
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
|
||||
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
|
||||
p, li { margin: 6px 0; }
|
||||
ul { padding-left: 24px; }
|
||||
.meta { color: #6b6b70; font-size: 14px; }
|
||||
.box { background: #fff; border: 2px solid #6d1fd1; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
|
||||
a { color: #6d1fd1; }
|
||||
nav.top { font-size: 14px; margin-bottom: 22px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav class="top"><a href="/">← Zur Startseite</a> · <a href="https://project.webklar.com/datenschutz.html">Datenschutzerklärung</a> · <a href="/impressum">Impressum</a></nav>
|
||||
<div class="brand">WEBKLAR</div>
|
||||
<h1>Datenschutz bei der Reichweitenmessung</h1>
|
||||
<p class="meta">Ergänzung zu unserer Datenschutzerklärung (Abschnitt 4a) · gilt für webklar.com · Stand 17.10.2026</p>
|
||||
<p>Diese Seite beschreibt nur die Reichweitenmessung auf webklar.com. Alle übrigen Hinweise finden Sie in unserer <a href="https://project.webklar.com/datenschutz.html">Datenschutzerklärung</a>.</p>
|
||||
|
||||
<h2>Verantwortlicher</h2>
|
||||
<p>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler<br>
|
||||
E-Mail: <a href="mailto:support@webklar.com">support@webklar.com</a></p>
|
||||
<p>Einen Datenschutzbeauftragten haben wir nicht benannt, weil wir dazu gesetzlich nicht verpflichtet sind.</p>
|
||||
|
||||
<h2>Worum es geht</h2>
|
||||
<p>Wir möchten verstehen, wie unsere Website genutzt wird, um sie zu verbessern: Welche Seiten werden gelesen, woher kommen die Besucher, wo wird geklickt? Dafür nutzen wir unsere eigene Website-Analyse, dieselbe, die wir auch unseren Kunden anbieten. Sie kommt ohne Cookies und ohne Dienste von Google, Meta oder anderen Werbenetzwerken aus. Wir nutzen die Daten nicht für Werbung und bilden keine Profile einzelner Besucher. Die Messung läuft auf unserem Server in Deutschland.</p>
|
||||
<p>Damit die Messung arbeiten kann, lädt Ihr Browser bei jedem Seitenaufruf ein Skript von unserem Server (project.webklar.com). Dabei erhält dieser Server, wie bei jeder Datei, die eine Website lädt, technisch Ihre IP-Adresse und Ihr Browser-Kennzeichen (User-Agent). Das gilt auch, wenn Sie ablehnen oder „Global Privacy Control“ aktiviert haben. Beim Abruf des Skripts und bei der Messung speichern und protokollieren wir beides nicht.</p>
|
||||
|
||||
<h2>Solange Sie im Banner nichts ausgewählt haben</h2>
|
||||
<p>Dann zählen wir den Aufruf der Seite. Das Skript übermittelt dafür nur die Adresse der Seite und die Angabe, dass das Banner angezeigt wurde. Gespeichert werden:</p>
|
||||
<ul>
|
||||
<li>der Seitenpfad (ohne Suchbegriffe oder Parameter hinter dem „?“),</li>
|
||||
<li>das Land, das wir aus Ihrer IP-Adresse ableiten,</li>
|
||||
<li>die Geräteklasse (Handy, Tablet oder Computer), die wir aus dem Browser-Kennzeichen ableiten,</li>
|
||||
<li>der Zeitpunkt, auf die volle Stunde gerundet, und</li>
|
||||
<li>ein Zähler dafür, dass das Banner angezeigt wurde.</li>
|
||||
</ul>
|
||||
<p>IP-Adresse und Browser-Kennzeichen verwenden wir nur kurz im Arbeitsspeicher: um Land und Geräteklasse zu bestimmen, um automatische Abrufe durch Programme (Bots) auszusortieren und um zu verhindern, dass die Messung durch massenhafte Anfragen verfälscht wird. Für diese Mengenbegrenzung bilden wir aus Ihrer IP-Adresse einen Prüfwert (Hash), den wir nur im Arbeitsspeicher halten und nicht speichern. Eine Kennung, mit der wir Sie wiedererkennen könnten, speichern wir ohne Ihre Einwilligung nicht.</p>
|
||||
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist, die Nutzung unserer Website in Zahlen zu kennen, das Banner zu betreiben und die Messung vor Missbrauch zu schützen.</p>
|
||||
|
||||
<h2>Wenn Sie „Ablehnen“ wählen</h2>
|
||||
<p>Dann zählen wir einmal, dass abgelehnt wurde, und das Skript sendet danach nichts mehr, auch keine Seitenaufrufe. Sendet Ihr Browser das Signal „Global Privacy Control“ oder „Do Not Track“, zeigen wir das Banner gar nicht an und zählen nichts.</p>
|
||||
|
||||
<h2>Wenn Sie „Annehmen“ wählen</h2>
|
||||
<p>Dann messen wir zusätzlich:</p>
|
||||
<ul>
|
||||
<li>von welcher Website Sie gekommen sind (nur der Domainname),</li>
|
||||
<li>die Größenklasse Ihres Browserfensters,</li>
|
||||
<li>eine Besucher-Kennung, die täglich wechselt. Wir berechnen sie aus IP-Adresse und Browser-Kennzeichen mit einem Schlüssel, der jeden Tag neu erzeugt und nicht gespeichert wird. Am selben Tag könnten wir die Kennung damit nachrechnen. Nach dem Wechsel des Schlüssels lässt sie sich nicht mehr aus Ihrer IP-Adresse berechnen und nicht mit Kennungen anderer Tage verknüpfen,</li>
|
||||
<li>wie lange Sie auf einer Seite bleiben (ab dem Laden der Seite) und wie weit Sie scrollen,</li>
|
||||
<li>wo auf der Seite geklickt wird: die Klickposition und den Aufbau des angeklickten Elements, ohne Texte und ohne Eingaben in Formulare (für eine „Heatmap“),</li>
|
||||
<li>ob Sie auf eine Telefonnummer, eine E-Mail-Adresse, einen WhatsApp-Link oder einen Link zu einer anderen Website klicken (dann mit deren Domainname, nicht mit der Nummer oder Adresse selbst), und</li>
|
||||
<li>einen Zähler dafür, dass Sie „Annehmen“ gewählt haben.</li>
|
||||
</ul>
|
||||
<p>Rechtsgrundlage ist Ihre Einwilligung nach § 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO.</p>
|
||||
|
||||
<h2>Was in Ihrem Browser gespeichert wird</h2>
|
||||
<p>Wir speichern Ihre Auswahl („Annehmen“ oder „Ablehnen“) mit Datum und der Fassung des Banner-Textes im lokalen Speicher Ihres Browsers (Eintrag „wk-consent“), damit wir Sie nicht bei jedem Seitenaufruf erneut fragen. Das ist ohne Einwilligung zulässig (§ 25 Abs. 2 Nr. 2 TDDDG). Die Auswahl gilt 183 Tage, danach fragen wir erneut. Sie können den Eintrag jederzeit selbst löschen, indem Sie die Website-Daten in Ihrem Browser löschen.</p>
|
||||
|
||||
<h2>Speicherdauer</h2>
|
||||
<p>Einzelne Seitenaufrufe, Ereignisse und Klickpositionen löschen wir nach 30 Tagen. Danach bleiben nur zusammengefasste Tageswerte, also Summen je Tag, etwa Aufrufe je Seite oder je Land, ohne Besucher-Kennung. Wir löschen sie nach 25 Monaten. Beenden wir die Messung, löschen wir alle Messdaten spätestens rund 60 Tage danach. Unser Dienstleister sichert den Server täglich; gelöschte Daten bleiben dort bis zu sieben Tage erhalten.</p>
|
||||
|
||||
<h2>Empfänger</h2>
|
||||
<p>Die Messung betreiben wir selbst. Die Daten sehen nur unsere Mitarbeiter. Der Server steht bei der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, in Deutschland; Hetzner ist für uns als Auftragsverarbeiter tätig. Eine Übermittlung in Länder außerhalb der EU bzw. des EWR findet nicht statt.</p>
|
||||
|
||||
<h2>Freiwilligkeit</h2>
|
||||
<p>Sie sind weder gesetzlich noch vertraglich verpflichtet, uns diese Daten bereitzustellen. Wenn Sie ablehnen oder Ihre Einwilligung widerrufen, können Sie unsere Website trotzdem uneingeschränkt nutzen. Eine automatisierte Entscheidungsfindung findet nicht statt.</p>
|
||||
|
||||
<div class="box">
|
||||
<h2 style="border:0;margin-top:0;padding-top:0">Einwilligung widerrufen</h2>
|
||||
<p>Sie können Ihre Einwilligung jederzeit mit Wirkung für die Zukunft widerrufen: Klicken Sie auf „Datenschutz-Einstellungen“ (das Keks-Symbol unten rechts auf jeder Seite) und wählen Sie „Ablehnen“. Die Rechtmäßigkeit der Verarbeitung bis zum Widerruf bleibt unberührt.</p>
|
||||
<h2 style="border:0;padding-top:0">Widerspruchsrecht</h2>
|
||||
<p>Sie können der Zählung nach Art. 6 Abs. 1 lit. f DSGVO jederzeit aus Gründen widersprechen, die sich aus Ihrer besonderen Situation ergeben (Art. 21 DSGVO). Am einfachsten wählen Sie im Banner oder über „Datenschutz-Einstellungen“ (das Keks-Symbol) „Ablehnen“ oder aktivieren in Ihrem Browser das Signal „Global Privacy Control“. Dann sendet das Skript keine Daten mehr. Sie können Ihren Widerspruch auch an die oben genannte E-Mail-Adresse richten.</p>
|
||||
</div>
|
||||
|
||||
<h2>Ihre Rechte</h2>
|
||||
<p>Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit (Art. 15 bis 18 und 20 DSGVO). In den Messdaten speichern wir keine Namen, keine IP-Adressen und keine Kennung, die länger als einen Tag gilt, und wir runden Zeitpunkte auf die volle Stunde. Deshalb können wir gespeicherte Messdaten in der Regel nicht Ihnen zuordnen (Art. 11 DSGVO). Anders kann es sein, solange die Zugriffsprotokolle unseres Webservers noch vorhanden sind (Abschnitt 3 der Datenschutzerklärung). Wenn Sie uns Datum, ungefähre Uhrzeit, besuchte Seiten und, wenn Sie möchten, Ihre IP-Adresse nennen, prüfen wir, ob eine Zuordnung möglich ist. Ist das nicht möglich, teilen wir Ihnen das mit.</p>
|
||||
<p>Mit Ihren Anliegen können Sie sich jederzeit an die oben genannte Adresse wenden. Außerdem haben Sie das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO), für uns zuständig ist der Landesbeauftragte für den Datenschutz und die Informationsfreiheit Rheinland-Pfalz.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Die Datenschutzerklärung liegt zentral im Kundenportal. Ein Anker aus der Adresse
|
||||
// (zum Beispiel #interessenten aus der Kaltmail) wird nur übernommen, wenn er
|
||||
// ausschließlich aus Buchstaben, Ziffern, Unterstrich und Bindestrich besteht.
|
||||
const BASIS = "https://project.webklar.com/datenschutz.html";
|
||||
|
||||
export const weiterleitungsZiel = (hash: string): string => (/^#[A-Za-z0-9_-]{1,64}$/.test(hash) ? BASIS + hash : BASIS);
|
||||
+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"}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useEffect } from "react";
|
||||
import { weiterleitungsZiel } from "@/lib/datenschutzZiel";
|
||||
|
||||
// Die Datenschutzerklärung gilt für webklar.com und das Kundenportal gemeinsam
|
||||
// und liegt zentral im Kundenportal.
|
||||
const URL = "https://project.webklar.com/datenschutz.html";
|
||||
|
||||
// und liegt zentral im Kundenportal. Ein Anker (#interessenten) geht dorthin mit.
|
||||
const Datenschutz = () => {
|
||||
const ziel = weiterleitungsZiel(window.location.hash);
|
||||
useEffect(() => {
|
||||
window.location.replace(URL);
|
||||
}, []);
|
||||
window.location.replace(ziel);
|
||||
}, [ziel]);
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-6 text-center">
|
||||
<p>
|
||||
Weiter zur <a href={URL} className="underline">Datenschutzerklärung</a> …
|
||||
Weiter zur <a href={ziel} className="underline">Datenschutzerklärung</a> …
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import Datenschutz from "@/pages/Datenschutz";
|
||||
import { weiterleitungsZiel } from "@/lib/datenschutzZiel";
|
||||
|
||||
const BASIS = "https://project.webklar.com/datenschutz.html";
|
||||
|
||||
describe("Weiterleitung /datenschutz", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
window.history.pushState({}, "", "/");
|
||||
});
|
||||
|
||||
it("nimmt einen gültigen Anker mit", () => {
|
||||
expect(weiterleitungsZiel("#interessenten")).toBe(`${BASIS}#interessenten`);
|
||||
expect(weiterleitungsZiel("#chat-anhaenge")).toBe(`${BASIS}#chat-anhaenge`);
|
||||
});
|
||||
it("ohne Anker oder mit ungültigem Anker bleibt es bei der Seite", () => {
|
||||
for (const hash of ["", "#", "#javascript:alert(1)", '#a"b', "#a b", `#${"a".repeat(65)}`]) {
|
||||
expect(weiterleitungsZiel(hash)).toBe(BASIS);
|
||||
}
|
||||
});
|
||||
it("der Link im Text trägt denselben Anker", () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {}); // jsdom meldet die nicht umgesetzte Navigation
|
||||
window.history.pushState({}, "", "/datenschutz#interessenten");
|
||||
render(<Datenschutz />);
|
||||
expect(screen.getByRole("link", { name: "Datenschutzerklärung" })).toHaveAttribute("href", `${BASIS}#interessenten`);
|
||||
});
|
||||
it("leitet mit dem Anker auf die Datenschutzerklärung im Portal weiter", () => {
|
||||
window.history.pushState({}, "", "/datenschutz#interessenten");
|
||||
const replace = vi.fn();
|
||||
vi.stubGlobal("location", { hash: window.location.hash, replace });
|
||||
render(<Datenschutz />);
|
||||
expect(replace).toHaveBeenCalledExactlyOnceWith(`${BASIS}#interessenten`);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Eigene Website-Analyse auf webklar.com: Skript des Messdienstes (Kundenportal) mit Banner in der Sie-Fassung
|
||||
// und eine eigene Datenschutz-Zusatzseite, auf die der Banner verlinkt (/datenschutz-analyse.html).
|
||||
const lies = (pfad: string) => readFileSync(join(process.cwd(), pfad), "utf8");
|
||||
const SCHLUESSEL = "yiy992rvq7vchfd7bqtqivah";
|
||||
|
||||
describe("Website-Analyse auf webklar.com", () => {
|
||||
it("bindet das Messskript genau einmal im head ein, mit Schlüssel, Farbe und Variante eigen", () => {
|
||||
const html = lies("index.html");
|
||||
const tags = html.match(/<script[^>]*data-wk-analytics[^>]*><\/script>/g) || [];
|
||||
expect(tags).toHaveLength(1);
|
||||
const tag = tags[0];
|
||||
expect(html.indexOf(tag)).toBeLessThan(html.indexOf("</head>"));
|
||||
expect(tag).toContain(`src="https://project.webklar.com/a/w.js?s=${SCHLUESSEL}"`);
|
||||
expect(tag).toContain(`data-site="${SCHLUESSEL}"`);
|
||||
expect(tag).toContain('data-variant="eigen"');
|
||||
expect(tag).toMatch(/data-color="#[0-9a-f]{6}"/);
|
||||
expect(tag).toContain(" defer");
|
||||
expect(tag).not.toContain('type="module"');
|
||||
});
|
||||
|
||||
it("die Zusatzseite nennt WEBklar als Verantwortlichen, siezt und hat keinen Auftragsverarbeiter WEBklar", () => {
|
||||
const seite = lies("public/datenschutz-analyse.html");
|
||||
const text = seite.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
|
||||
expect(text).toContain("WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler");
|
||||
expect(text).toContain("Die Messung betreiben wir selbst.");
|
||||
expect(text).toContain("§ 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO");
|
||||
expect(text).toContain("Art. 6 Abs. 1 lit. f DSGVO");
|
||||
expect(text).toContain("wk-consent");
|
||||
expect(text).toContain("Keks-Symbol");
|
||||
expect(text).not.toMatch(/\b(Du|Dein|Deine|Deiner|Dich|Dir)\b/);
|
||||
expect(text).not.toContain("als Auftragsverarbeiter. WEBklar darf");
|
||||
expect(seite).toContain('href="https://project.webklar.com/datenschutz.html"');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user