Compare commits
7
Commits
4139f31925
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3df23fabaf | ||
|
|
09e1832041 | ||
|
|
cbc149f9f8 | ||
|
|
795c79d393 | ||
|
|
8f5d522682 | ||
|
|
a23f2e52c2 | ||
|
|
f7147fcd30 |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -249,7 +249,7 @@ Texte in **[eckigen Klammern]** brauchen die Freigabe des Inhabers (Abschnitt 11
|
||||
- Keine festgepinnte Bühne.
|
||||
- Stattdessen eine **Wisch-Reihe** (9.4) mit je einem Handy-Screenshot im Rahmen und der Bildunterschrift darunter.
|
||||
- Die letzte Karte „Als App“ zeigt `phone-2-projekte-lang`. Es schiebt sich langsam nach oben, solange die Karte sichtbar ist.
|
||||
- Bei O9 = ja höchstens 7 Karten.
|
||||
- Bei O9 = ja höchstens 9 Karten (Stand mit „Öffnungszeiten live“: die Funktions-Reihe hat neun Stationen).
|
||||
|
||||
**Schrittzeile unter dem Portal** (ersetzt „So arbeiten wir“):
|
||||
- Text: „**1** Kennenlernen · **2** Angebot mit Preis · **3** Bauen mit Vorschau und Ihrer Freigabe · **4** Online – Hosting und Pflege auf Wunsch im Abo“
|
||||
@@ -496,7 +496,7 @@ Vor jedem Löschen prüfen, dass nichts mehr die Datei importiert.
|
||||
- Die Fokusreihenfolge entspricht der Anzeige.
|
||||
- Kein `aria-live`, keine automatische Bewegung.
|
||||
- **Ab 900 px** zeigt jeder Abschnitt seine Desktop-Darstellung (Liste, Raster oder Bühne). Auf dem Desktop bleibt keine Wisch-Reihe übrig.
|
||||
- **Grenze:** Höchstens 7 Karten je Reihe.
|
||||
- **Grenze:** Höchstens 9 Karten je Reihe (Stand mit „Öffnungszeiten live“; vorher 7).
|
||||
|
||||
### 9.5 Bilder
|
||||
- **Format und Auslieferung:**
|
||||
|
||||
@@ -130,7 +130,7 @@ reagiert auf Tippen (Zone antippen ⇒ Diagnose unter der Karte).
|
||||
möglichst als eigener Chunk (`React.lazy`), damit die Startseite nicht schwerer wird.
|
||||
- Inaktive Ebenen der Bühne bekommen `pointer-events: none`, damit nur die sichtbare Website auf die Maus reagiert.
|
||||
- Tests: Vitest je Demo (Rendern Desktop/Handy, statischer Endzustand ohne Bewegung, Diagnose-Logik,
|
||||
keine fokussierbaren Elemente in der Website); `relaunch-kundenportal.test.tsx` auf 8 Stationen angepasst.
|
||||
keine fokussierbaren Elemente in der Website); `relaunch-kundenportal.test.tsx` auf 8 Stationen angepasst (seit „Öffnungszeiten live“: 9 Stationen).
|
||||
`sofort-paket.test.tsx` (Scans gegen verbotene Aussagen) bleibt grün.
|
||||
|
||||
## 5. Offen für den Inhaber
|
||||
|
||||
@@ -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: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -58,6 +58,7 @@ BILDER = {
|
||||
"phone-9-termine": ("handy", (390, 780)),
|
||||
"phone-10-analyse": ("handy", (390, 780)),
|
||||
"phone-11-news-musterbach": ("handy", (390, 780)),
|
||||
"phone-12-zeiten": ("handy", (390, 780)),
|
||||
# langer Screenshot: behält sein hohes Seitenverhältnis (eigenes Budget, siehe BUDGET_LANG)
|
||||
"phone-2-projekte-lang": ("handy", (390, 780)),
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { TerminZustand } from "./demos/termin/ablauf";
|
||||
import type { HeatmapZustand } from "./demos/heatmap/ablauf";
|
||||
import type { PinnwandZustand } from "./demos/pinnwand/ablauf";
|
||||
import type { ChatZustand } from "./demos/chat/ablauf";
|
||||
import type { ZeitenZustand } from "./demos/zeiten/ablauf";
|
||||
import "./kundenportal.css";
|
||||
|
||||
/**
|
||||
@@ -25,7 +26,7 @@ import "./kundenportal.css";
|
||||
* Ab 900 px: festgepinnte Bühne mit Funktionsliste links und Desktop-Fenster plus Handy-Rahmen rechts.
|
||||
* Darunter (unter 900 px): keine Bühne, sondern eine Wisch-Reihe mit je einem Handy-Screenshot.
|
||||
* Danach die Schrittzeile (ersetzt "So arbeiten wir").
|
||||
* Die Stationen 5 bis 7 (Terminplaner, Website-Analyse, News-Pinnwand) zeigen statt eines Bildes eine
|
||||
* Die Stationen 5 bis 8 (Terminplaner, Website-Analyse, News-Pinnwand, Öffnungszeiten live) zeigen statt eines Bildes eine
|
||||
* Live-Demo: auf dem MacBook die erfundene Kunden-Website, auf dem iPhone das Portal (Spezifikation
|
||||
* 2026-10-04-funktionen-demos). Die Demos werden per React.lazy erst geladen, wenn die Bühne ihre Station fast erreicht.
|
||||
* Live sind außerdem (Wunsch des Inhabers 2026-10-04, Spezifikation 2026-10-04-vorschau-chat):
|
||||
@@ -67,7 +68,7 @@ const handyBild = (name: string, alt: string, sizes: string, hoehe = 1696): Rahm
|
||||
alt,
|
||||
});
|
||||
|
||||
type DemoArt = "termin" | "heatmap" | "pinnwand";
|
||||
type DemoArt = "termin" | "heatmap" | "pinnwand" | "zeiten";
|
||||
/** Live-Stationen außerhalb der Gruppe „Funktionen“ */
|
||||
type LiveArt = "vorschau" | "chat";
|
||||
type LadeArt = DemoArt | LiveArt;
|
||||
@@ -94,9 +95,9 @@ type Funktion = {
|
||||
adresse?: string;
|
||||
/** Beschreibung der Beispiel-Website für Screenreader (die Website selbst ist aria-hidden). */
|
||||
beschreibung?: string;
|
||||
/** Hinweis unter dem MacBook (Live-Vorschau, Chat, Terminplaner, Pinnwand; bei der Website-Analyse steht dort die Diagnose). */
|
||||
/** Hinweis unter dem MacBook (Live-Vorschau, Chat, Terminplaner, Pinnwand; bei der Website-Analyse steht dort die Diagnose, bei Öffnungszeiten live der Uhr-Regler). */
|
||||
hinweis?: string;
|
||||
/** Noch nicht für Kunden buchbar: Marke „Bald verfügbar“ am Titel (Terminplaner läuft bisher nur im Pilot, Stand 04.10.2026). */
|
||||
/** Noch nicht für Kunden buchbar: Marke „Bald verfügbar“ am Titel (Terminplaner läuft bisher nur im Pilot, Stand 04.10.2026; Öffnungszeiten live bis zum Portal-Livegang). */
|
||||
bald?: boolean;
|
||||
};
|
||||
|
||||
@@ -104,6 +105,8 @@ const BaldMarke = ({ f }: { f: Funktion }) => (f.bald ? <span className="kp-bald
|
||||
|
||||
/** Mitte des Knopfes "Aufhängen" im Portal-Screenshot der News-Pinnwand (Designpunkte der iPhone-Fläche 402 x 874). */
|
||||
const AUFHAENGEN_PUNKT = { x: 201, y: 613 };
|
||||
/** Mitte des Knopfes "Hinweis-Leiste anlegen" im Portal-Screenshot der Station Öffnungszeiten live (phone-12-zeiten, Designpunkte der iPhone-Fläche 402 x 874; gemessen 135,3 / 487,9). Gleicher Wert wie HINWEIS_ANLEGEN_PUNKT in demos/zeiten/ablauf.ts (der Test pinnt beide). */
|
||||
const HINWEIS_PUNKT = { x: 135, y: 488 };
|
||||
|
||||
// Reihenfolge und Texte laut Spezifikation 7.2. Platzhalter des Portals ("kommt bald") werden nicht beworben,
|
||||
// weitere Ansichten nur nach Freigabe des Inhabers (O9 = nein).
|
||||
@@ -191,6 +194,16 @@ const FUNKTIONEN: Funktion[] = [
|
||||
alt: "Kundenportal von WEBklar auf dem Handy: Ein neuer Zettel für die News-Pinnwand mit Überschrift und Text ist ausgefüllt, darunter der Knopf zum Aufhängen auf der Website.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "oeffnungszeiten",
|
||||
titel: "Öffnungszeiten live",
|
||||
demo: "zeiten",
|
||||
bald: true,
|
||||
adresse: "baeckerei-sonnenschein.example",
|
||||
beschreibung:
|
||||
"Beispiel-Website einer Bäckerei mit Öffnungszeiten: Der Status „Jetzt geöffnet“ folgt der Uhrzeit, danach kündigt eine Hinweis-Leiste den Betriebsurlaub an",
|
||||
handy: { name: "phone-12-zeiten", alt: "Öffnungszeiten im Kundenportal bearbeiten" },
|
||||
},
|
||||
{
|
||||
id: "als-app",
|
||||
titel: "Als App",
|
||||
@@ -239,12 +252,16 @@ const ChatHandy = lazyMitVorladen(() => import("./demos/chat/ChatHandy"));
|
||||
const TerminSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.TerminSteuerung })));
|
||||
const HeatmapSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.HeatmapSteuerung })));
|
||||
const PinnwandSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.PinnwandSteuerung })));
|
||||
const ZeitenSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.ZeitenSteuerung })));
|
||||
const TerminSeite = lazyMitVorladen(() => import("./demos/termin/TerminSeite"));
|
||||
const TerminHandy = lazyMitVorladen(() => import("./demos/termin/TerminHandy"));
|
||||
const HeatmapSeite = lazyMitVorladen(() => import("./demos/heatmap/HeatmapSeite"));
|
||||
const HeatmapDiagnose = lazyMitVorladen(() => import("./demos/heatmap/HeatmapDiagnose"));
|
||||
const PinnwandSeite = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandSeite"));
|
||||
const PinnwandHandy = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandHandy"));
|
||||
const ZeitenSeite = lazyMitVorladen(() => import("./demos/zeiten/ZeitenSeite"));
|
||||
const ZeitenHandy = lazyMitVorladen(() => import("./demos/zeiten/ZeitenHandy"));
|
||||
const ZeitenRegler = lazyMitVorladen(() => import("./demos/zeiten/ZeitenRegler"));
|
||||
|
||||
/** Chunks je Demo; die Website-Chunks (Seite, Diagnose) und der Taktgeber laden gleichzeitig. */
|
||||
const VORLADEN: Record<LadeArt, (() => void)[]> = {
|
||||
@@ -254,6 +271,7 @@ const VORLADEN: Record<LadeArt, (() => void)[]> = {
|
||||
termin: [TerminSteuerung.vorladen, TerminSeite.vorladen, TerminHandy.vorladen],
|
||||
heatmap: [HeatmapSteuerung.vorladen, HeatmapSeite.vorladen, HeatmapDiagnose.vorladen],
|
||||
pinnwand: [PinnwandSteuerung.vorladen, PinnwandSeite.vorladen, PinnwandHandy.vorladen],
|
||||
zeiten: [ZeitenSteuerung.vorladen, ZeitenSeite.vorladen, ZeitenHandy.vorladen, ZeitenRegler.vorladen],
|
||||
};
|
||||
|
||||
/** Hintergrund der Beispiel-Websites, solange ihr Chunk lädt (statt einer schwarzen Fläche im MacBook). */
|
||||
@@ -263,6 +281,7 @@ const PLATZHALTER: Record<LadeArt, string> = {
|
||||
termin: "#f6f1ea",
|
||||
heatmap: "#141414",
|
||||
pinnwand: "#f7f2e6",
|
||||
zeiten: "#f7f0e4",
|
||||
};
|
||||
const Platzhalter = ({ art }: { art: LadeArt }) => (
|
||||
<div className="kp-platzhalter" style={{ background: PLATZHALTER[art] }} aria-hidden="true" />
|
||||
@@ -331,6 +350,7 @@ const Buehne = () => {
|
||||
const [termin, setTermin] = useState<TerminZustand | null>(null);
|
||||
const [heat, setHeat] = useState<HeatmapZustand | null>(null);
|
||||
const [pinnwand, setPinnwand] = useState<PinnwandZustand | null>(null);
|
||||
const [zeiten, setZeiten] = useState<ZeitenZustand | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof IntersectionObserver === "undefined") return;
|
||||
@@ -488,6 +508,14 @@ const Buehne = () => {
|
||||
{demoEingehaengt("pinnwand") ? (
|
||||
<PinnwandSteuerung laeuft={lauf.laeuft && aktivDemo === "pinnwand"} statisch={lauf.statisch} onZustand={setPinnwand} />
|
||||
) : null}
|
||||
{demoEingehaengt("zeiten") ? (
|
||||
<ZeitenSteuerung
|
||||
laeuft={lauf.laeuft && aktivDemo === "zeiten"}
|
||||
statisch={lauf.statisch}
|
||||
aktiv={aktivDemo === "zeiten"}
|
||||
onZustand={setZeiten}
|
||||
/>
|
||||
) : null}
|
||||
</Suspense>
|
||||
|
||||
<div className="kp-spur" aria-hidden="true">
|
||||
@@ -596,6 +624,7 @@ const Buehne = () => {
|
||||
{f.demo === "termin" ? <TerminSeite zustand={termin ?? undefined} variante="desktop" /> : null}
|
||||
{f.demo === "heatmap" ? <HeatmapSeite zustand={heat ?? undefined} variante="desktop" /> : null}
|
||||
{f.demo === "pinnwand" ? <PinnwandSeite zustand={pinnwand ?? undefined} variante="desktop" /> : null}
|
||||
{f.demo === "zeiten" ? <ZeitenSeite zustand={zeiten ?? undefined} variante="desktop" /> : null}
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -625,6 +654,7 @@ const Buehne = () => {
|
||||
<Suspense fallback={null}>
|
||||
{f.demo === "termin" && termin ? <TerminHandy zustand={termin} /> : null}
|
||||
{f.demo === "pinnwand" && pinnwand ? <PinnwandHandy zustand={pinnwand} punkt={AUFHAENGEN_PUNKT} /> : null}
|
||||
{f.demo === "zeiten" && zeiten ? <ZeitenHandy zustand={zeiten} punkt={HINWEIS_PUNKT} /> : null}
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -664,6 +694,19 @@ const Buehne = () => {
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Öffnungszeiten live: der Uhr-Regler (echter Schieberegler), nur bei aktiver Station sichtbar und bedienbar */}
|
||||
<div
|
||||
className="kp-regler"
|
||||
data-an={aktivDemo === "zeiten" ? "ja" : "nein"}
|
||||
aria-hidden={aktivDemo !== "zeiten" || undefined}
|
||||
{...(aktivDemo !== "zeiten" ? INERT : null)}
|
||||
>
|
||||
{demoEingehaengt("zeiten") ? (
|
||||
<Suspense fallback={null}>
|
||||
<ZeitenRegler zustand={zeiten ?? undefined} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
{FUNKTIONEN.filter((f) => ladeArt(f) && f.hinweis).map((f) => (
|
||||
<p
|
||||
key={f.id}
|
||||
@@ -803,6 +846,26 @@ const PinnwandKarte = ({ f }: { f: Funktion }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Öffnungszeiten-Karte: die Website im Handy, darunter der Uhr-Regler (Ziehen ändert Status und Uhrzeit auf der Website). */
|
||||
const ZeitenKarte = ({ f }: { f: Funktion }) => {
|
||||
const k = useKarte("zeiten");
|
||||
const [z, setZ] = useState<ZeitenZustand | null>(null);
|
||||
return (
|
||||
<DemoKarteRahmen
|
||||
f={f}
|
||||
geraetRef={k.ref}
|
||||
nachher={
|
||||
<div className="kp-karte__regler">
|
||||
<Suspense fallback={null}>{k.gesehen ? <ZeitenRegler zustand={z ?? undefined} kompakt /> : null}</Suspense>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{k.gesehen ? <ZeitenSteuerung laeuft={k.laeuft} statisch={k.statisch} aktiv onZustand={setZ} /> : null}
|
||||
{k.gesehen ? <ZeitenSeite zustand={z ?? undefined} variante="handy" /> : null}
|
||||
</DemoKarteRahmen>
|
||||
);
|
||||
};
|
||||
|
||||
/** Heatmap-Karte: Tippen auf eine Zone wählt sie, darunter erscheint die kompakte Diagnose. */
|
||||
const HeatmapKarte = ({ f }: { f: Funktion }) => {
|
||||
const k = useKarte("heatmap");
|
||||
@@ -825,13 +888,14 @@ const HeatmapKarte = ({ f }: { f: Funktion }) => {
|
||||
|
||||
const HandyReihe = () => (
|
||||
<div className="kp-reihe">
|
||||
<WischReihe ariaLabel="Acht Funktionen des Kundenportals, seitlich wischen">
|
||||
<WischReihe ariaLabel="Neun Funktionen des Kundenportals, seitlich wischen">
|
||||
{FUNKTIONEN.map((f) => {
|
||||
if (f.lang) return <LangeKarte key={f.id} f={f} />;
|
||||
if (f.live === "chat") return <ChatKarte key={f.id} f={f} />;
|
||||
if (f.demo === "termin") return <TerminKarte key={f.id} f={f} />;
|
||||
if (f.demo === "heatmap") return <HeatmapKarte key={f.id} f={f} />;
|
||||
if (f.demo === "pinnwand") return <PinnwandKarte key={f.id} f={f} />;
|
||||
if (f.demo === "zeiten") return <ZeitenKarte key={f.id} f={f} />;
|
||||
return (
|
||||
<div key={f.id} className="kp-karte">
|
||||
<HandyRahmen className="kp-karte__handy" bild={handyBild(f.handy.name, f.handy.alt, "250px")} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useId, useLayoutEffect, useRef } from "react";
|
||||
import { memo, useLayoutEffect, useRef } from "react";
|
||||
import { FLAECHE } from "../gemeinsam";
|
||||
import { Logo, Pfeil, Stempel } from "./teile";
|
||||
import "./baeckerei.css";
|
||||
|
||||
/**
|
||||
@@ -14,45 +15,6 @@ import "./baeckerei.css";
|
||||
const FOTOS = "/relaunch/demos/baeckerei";
|
||||
const foto = (name: string) => `${FOTOS}/${name}.webp`;
|
||||
|
||||
/** Logo: kleine Sonne über einem Brotlaib, in der Farbe der Kruste. */
|
||||
const Logo = () => (
|
||||
<svg className="bk-logo__zeichen" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
|
||||
<circle cx="24" cy="24" r="23" fill="#2a1a10" />
|
||||
<g stroke="#f2b544" strokeWidth="2.6" strokeLinecap="round">
|
||||
<path d="M24 8v4M13 12.5l2.6 2.8M35 12.5l-2.6 2.8M8.5 22h3.6M39.5 22h-3.6" />
|
||||
</g>
|
||||
<path d="M16.5 26a7.5 7.5 0 0 1 15 0" fill="#f2b544" />
|
||||
<path d="M10.5 33.5c0-4.2 6-6.4 13.5-6.4s13.5 2.2 13.5 6.4c0 1.7-1.4 2.6-3.2 2.6H13.7c-1.8 0-3.2-.9-3.2-2.6z" fill="#e8d3b2" />
|
||||
<path d="M17 30.5l2.4 2.6M23 29.8l2.4 2.6M29 30.5l2.4 2.6" stroke="#2a1a10" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Runder Stempel „Handwerk · seit 1987 · Altstadt ·“ (Schrift auf einer Kreisbahn, auf den Umfang gestreckt). */
|
||||
const Stempel = () => {
|
||||
const id = `${useId().replace(/:/g, "")}-kreis`;
|
||||
return (
|
||||
<svg className="bk-stempel" viewBox="0 0 120 120" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<path id={id} d="M60 60m-44 0a44 44 0 1 1 88 0a44 44 0 1 1-88 0" />
|
||||
</defs>
|
||||
<circle cx="60" cy="60" r="58" fill="#f2b544" />
|
||||
<text fontSize="10.5" fontWeight="600" fill="#2a1a10">
|
||||
<textPath href={`#${id}`} textLength="272" lengthAdjust="spacing">
|
||||
HANDWERK · SEIT 1987 · ALTSTADT ·
|
||||
</textPath>
|
||||
</text>
|
||||
<path d="M44 66c0-6 7.2-9.2 16-9.2s16 3.2 16 9.2c0 2.2-1.8 3.4-4 3.4H48c-2.2 0-4-1.2-4-3.4z" fill="#2a1a10" />
|
||||
<path d="M52 62l2.6 2.8M59 61l2.6 2.8M66 62l2.6 2.8" stroke="#f2b544" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const Pfeil = () => (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<path d="M3 8h9.5M8.5 4l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SORTIMENT = [
|
||||
{ name: "Roggenbrot", text: "36 Stunden Sauerteig, kräftig und lange saftig.", preis: "4,90 €", bild: "roggenbrot-anschnitt-400", neu: true },
|
||||
{ name: "Kaiserbrötchen", text: "Außen knusprig, innen fluffig – ab 6 Uhr.", preis: "0,55 €", bild: "broetchen-400" },
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useId } from "react";
|
||||
|
||||
/**
|
||||
* Gemeinsame Teile der Bäckerei Sonnenschein: Logo, Stempel und Pfeil (aus BaeckereiSeite.tsx unverändert hierher
|
||||
* verschoben, damit die Seite der Station „Öffnungszeiten live“ sie ebenfalls nutzt). Die Klassen (.bk-logo__zeichen,
|
||||
* .bk-stempel) stylt baeckerei.css; wer die Teile nutzt, bindet diese Datei ein.
|
||||
*/
|
||||
|
||||
/** Logo: kleine Sonne über einem Brotlaib, in der Farbe der Kruste. */
|
||||
export const Logo = () => (
|
||||
<svg className="bk-logo__zeichen" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
|
||||
<circle cx="24" cy="24" r="23" fill="#2a1a10" />
|
||||
<g stroke="#f2b544" strokeWidth="2.6" strokeLinecap="round">
|
||||
<path d="M24 8v4M13 12.5l2.6 2.8M35 12.5l-2.6 2.8M8.5 22h3.6M39.5 22h-3.6" />
|
||||
</g>
|
||||
<path d="M16.5 26a7.5 7.5 0 0 1 15 0" fill="#f2b544" />
|
||||
<path d="M10.5 33.5c0-4.2 6-6.4 13.5-6.4s13.5 2.2 13.5 6.4c0 1.7-1.4 2.6-3.2 2.6H13.7c-1.8 0-3.2-.9-3.2-2.6z" fill="#e8d3b2" />
|
||||
<path d="M17 30.5l2.4 2.6M23 29.8l2.4 2.6M29 30.5l2.4 2.6" stroke="#2a1a10" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Runder Stempel „Handwerk · seit 1987 · Altstadt ·“ (Schrift auf einer Kreisbahn, auf den Umfang gestreckt). */
|
||||
export const Stempel = () => {
|
||||
const id = `${useId().replace(/:/g, "")}-kreis`;
|
||||
return (
|
||||
<svg className="bk-stempel" viewBox="0 0 120 120" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<path id={id} d="M60 60m-44 0a44 44 0 1 1 88 0a44 44 0 1 1-88 0" />
|
||||
</defs>
|
||||
<circle cx="60" cy="60" r="58" fill="#f2b544" />
|
||||
<text fontSize="10.5" fontWeight="600" fill="#2a1a10">
|
||||
<textPath href={`#${id}`} textLength="272" lengthAdjust="spacing">
|
||||
HANDWERK · SEIT 1987 · ALTSTADT ·
|
||||
</textPath>
|
||||
</text>
|
||||
<path d="M44 66c0-6 7.2-9.2 16-9.2s16 3.2 16 9.2c0 2.2-1.8 3.4-4 3.4H48c-2.2 0-4-1.2-4-3.4z" fill="#2a1a10" />
|
||||
<path d="M52 62l2.6 2.8M59 61l2.6 2.8M66 62l2.6 2.8" stroke="#f2b544" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const Pfeil = () => (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<path d="M3 8h9.5M8.5 4l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
@@ -3,9 +3,10 @@ import { useTerminAblauf, type TerminZustand } from "./termin/ablauf";
|
||||
import { useHeatmapAblauf, type HeatmapZustand } from "./heatmap/ablauf";
|
||||
import { usePinnwandAblauf, type PinnwandZustand } from "./pinnwand/ablauf";
|
||||
import { useChatAblauf, type ChatZustand } from "./chat/ablauf";
|
||||
import { useZeitenAblauf, type ZeitenZustand } from "./zeiten/ablauf";
|
||||
|
||||
/**
|
||||
* Taktgeber der Demos (drei Funktionen und der Chat) als unsichtbare Bauteile (rendern nichts, melden nur ihren Zustand).
|
||||
* Taktgeber der Demos (vier Funktionen und der Chat) als unsichtbare Bauteile (rendern nichts, melden nur ihren Zustand).
|
||||
* Sie liegen in einem eigenen Chunk, damit die Startseite die Ablauf-Logik erst lädt, wenn die Kundenportal-Bühne
|
||||
* eine Demo-Station fast erreicht (Spezifikation 4). Die Bühne hält den gemeldeten Zustand und gibt denselben
|
||||
* Zustand an Website, Handy-Ebene und Diagnose.
|
||||
@@ -51,3 +52,19 @@ export const HeatmapSteuerung = memo(function HeatmapSteuerung({
|
||||
useEffect(() => onZustand(zustand), [zustand, onZustand]);
|
||||
return null;
|
||||
});
|
||||
|
||||
/** `aktiv`: Station ist die aktive; sonst geht eine Bedienung des Uhr-Reglers zurück an den Ablauf (beim nächsten Mal von vorn). */
|
||||
export const ZeitenSteuerung = memo(function ZeitenSteuerung({
|
||||
laeuft,
|
||||
statisch,
|
||||
aktiv,
|
||||
onZustand,
|
||||
}: Props<ZeitenZustand> & { aktiv: boolean }) {
|
||||
const zustand = useZeitenAblauf(laeuft, statisch);
|
||||
const { setzeUhr } = zustand;
|
||||
useEffect(() => {
|
||||
if (!aktiv) setzeUhr(null);
|
||||
}, [aktiv, setzeUhr]);
|
||||
useEffect(() => onZustand(zustand), [zustand, onZustand]);
|
||||
return null;
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { DemoZeiger } from "../gemeinsam";
|
||||
import { HINWEIS_ANLEGEN_PUNKT, type ZeitenZustand } from "./ablauf";
|
||||
|
||||
/** Wo der Finger wartet, bevor er zum Knopf gleitet (unten rechts, neben dem Home-Balken). */
|
||||
const RUHE = { x: 318, y: 842 };
|
||||
|
||||
// Inline, damit die Ebene die Fläche genau überdeckt und nichts anklickt, auch ohne geladenes CSS.
|
||||
const EBENE = { position: "absolute", inset: 0, pointerEvents: "none", overflow: "hidden" } as const;
|
||||
|
||||
type Props = {
|
||||
zustand: ZeitenZustand;
|
||||
/** Mitte des Knopfes „Hinweis-Leiste anlegen“ im Portal-Bild; Standard: HINWEIS_ANLEGEN_PUNKT. */
|
||||
punkt?: { x: number; y: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* Live-Ebene über dem Portal-Screenshot im iPhone der Bühne (402 x 874, transparent, pointer-events none):
|
||||
* in Phase „tippen“ gleitet ein Finger auf „Hinweis-Leiste anlegen“ und tippt; danach gleitet auf der Website die Leiste ein.
|
||||
* Davor (Uhr läuft) wartet der Finger unsichtbar in der Ruhelage, damit er beim Start von dort zum Knopf gleitet.
|
||||
*/
|
||||
const ZeitenHandy = ({ zustand, punkt = HINWEIS_ANLEGEN_PUNKT }: Props) => {
|
||||
const amKnopf = zustand.phase === "tippen" || zustand.phase === "leiste";
|
||||
const ziel = amKnopf ? punkt : RUHE;
|
||||
return (
|
||||
<div style={EBENE} aria-hidden="true" data-phase={zustand.phase}>
|
||||
<DemoZeiger x={ziel.x} y={ziel.y} art="finger" klick={zustand.klick} sichtbar={zustand.phase === "tippen"} groesse={1.5} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZeitenHandy;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useId } from "react";
|
||||
import { ZEITEN_START, ZEIT_MAX, ZEIT_MIN, ZEIT_SCHRITT, formatUhr, type ZeitenZustand } from "./ablauf";
|
||||
import "./zeiten.css";
|
||||
|
||||
/**
|
||||
* Der Uhr-Regler unter dem MacBook (Station „Öffnungszeiten live“): ein echter Schieberegler (input type=range) mit Label,
|
||||
* der Uhrzeit und dem Statussatz der Bäckerei zu dieser Uhrzeit. Wer ihn anfasst, hält den Automatiklauf an;
|
||||
* die Website zeigt dann den Status der gewählten Uhrzeit. Normaler Seitenmaßstab mit den Seitentoken, hell und dunkel.
|
||||
* `kompakt` ist für die Handy-Karte (schmal, bis 320 px).
|
||||
* Der Statussatz meldet sich Screenreadern (aria-live) nur nach einer Bedienung, nicht beim Automatiklauf.
|
||||
*/
|
||||
|
||||
type Props = { zustand?: ZeitenZustand; kompakt?: boolean };
|
||||
|
||||
const ZeitenRegler = ({ zustand = ZEITEN_START, kompakt = false }: Props) => {
|
||||
const id = useId();
|
||||
const { uhr, status, bedient } = zustand;
|
||||
return (
|
||||
<div className={`zr${kompakt ? " zr--kompakt" : ""}`} data-bedient={bedient ? "ja" : "nein"}>
|
||||
<div className="zr__kopf">
|
||||
<label className="zr__titel" htmlFor={id}>
|
||||
Uhrzeit testen
|
||||
</label>
|
||||
<span className="zr__uhr" aria-hidden="true">
|
||||
{formatUhr(uhr)}
|
||||
{" "}Uhr
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id={id}
|
||||
className="zr__regler"
|
||||
type="range"
|
||||
min={ZEIT_MIN}
|
||||
max={ZEIT_MAX}
|
||||
step={ZEIT_SCHRITT}
|
||||
value={uhr}
|
||||
aria-valuetext={`${formatUhr(uhr)} Uhr, ${status.kurz}`}
|
||||
onChange={(e) => zustand.setzeUhr(Number(e.target.value))}
|
||||
/>
|
||||
<p className="zr__status" data-offen={status.offen ? "ja" : "nein"} aria-live={bedient ? "polite" : "off"} aria-atomic="true">
|
||||
<i className="zr__punkt" aria-hidden="true" />
|
||||
{status.text}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZeitenRegler;
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { DemoVariante } from "../gemeinsam";
|
||||
import { Logo, Pfeil, Stempel } from "../baeckerei/teile";
|
||||
import { HINWEIS_TEXT, ZEITEN_START, type ZeitenZustand } from "./ablauf";
|
||||
import "../baeckerei/baeckerei.css";
|
||||
import "./zeiten.css";
|
||||
|
||||
/**
|
||||
* Die Beispiel-Website der Station „Öffnungszeiten live“: wieder die erfundene „Bäckerei Sonnenschein“ (Spezifikation 10),
|
||||
* hier mit großer Status-Pille („Jetzt geöffnet · schließt um 18 Uhr“) und der Hinweis-Leiste „Betriebsurlaub 12.–26. Oktober“,
|
||||
* die oben einschiebt, sobald der Inhaber sie im Portal anlegt (Phase „leiste“; die Seite rückt per transform nach).
|
||||
* Demo-Datum: Montag, 5. Oktober 2026 (Mo bis Fr 6 bis 18 Uhr), die Uhrzeit kommt aus dem Zustand.
|
||||
* Desktop = 1512 x 893 (MacBook-Fläche), Handy = 402 x 874 (oben 59 / unten 34 frei für Statusleiste und Home-Balken).
|
||||
* Eigenes Wurzelelement ohne .bk-inhalt: die Seite läuft nicht mit dem Scrollfortschritt der Live-Vorschau mit.
|
||||
* Die Website ist reine Kulisse: aria-hidden, keine fokussierbaren Elemente (div/span statt a/button).
|
||||
*/
|
||||
|
||||
const FOTO = "/relaunch/demos/baeckerei/roggenbrot-600.webp";
|
||||
|
||||
/** Wie in der Live-Vorschau: Montag bis Freitag 6 bis 18 Uhr; der Demo-Tag ist ein Montag. */
|
||||
const ZEITEN = [
|
||||
{ tage: "Mo – Fr", zeit: "6:00 – 18:00 Uhr", heute: true },
|
||||
{ tage: "Sa", zeit: "6:00 – 13:00 Uhr" },
|
||||
{ tage: "So", zeit: "7:00 – 11:00 Uhr" },
|
||||
];
|
||||
|
||||
const MENUE = ["Sortiment", "Backstube", "Café", "Torten", "Kontakt"];
|
||||
const BANDWOERTER = ["Roggenbrot", "Dinkelvollkorn", "Buttercroissant", "Laugenbrezel", "Zimtschnecke", "Zwetschgenkuchen"];
|
||||
|
||||
const Karte = () => (
|
||||
<div className="zt-karte">
|
||||
<span className="bk-dach">Öffnungszeiten</span>
|
||||
{ZEITEN.map((z) => (
|
||||
<div key={z.tage} className={`zt-zeile${z.heute ? " is-heute" : ""}`}>
|
||||
<span>{z.tage}</span>
|
||||
<span>{z.zeit}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** `zustand` fehlt, solange der Taktgeber noch lädt: dann steht die Website im Anfangszustand da. */
|
||||
type Props = { zustand?: ZeitenZustand; variante: DemoVariante };
|
||||
|
||||
/** Die Website auf der MacBook-Fläche (desktop) oder in der Handy-Fassung (handy). */
|
||||
const ZeitenSeite = ({ zustand = ZEITEN_START, variante }: Props) => {
|
||||
const desktop = variante === "desktop";
|
||||
const { status } = zustand;
|
||||
return (
|
||||
<div
|
||||
className="bk-seite zt-seite"
|
||||
lang="de"
|
||||
aria-hidden="true"
|
||||
data-variante={variante}
|
||||
data-phase={zustand.phase}
|
||||
data-laeuft={zustand.laeuft ? "ja" : "nein"}
|
||||
>
|
||||
<div className="zt-leiste">
|
||||
<i className="zt-leiste__punkt" />
|
||||
{HINWEIS_TEXT}
|
||||
</div>
|
||||
|
||||
<div className="zt-inhalt">
|
||||
<div className="bk-kopf zt-kopf">
|
||||
<div className="bk-logo">
|
||||
<Logo />
|
||||
<span className="bk-logo__text">
|
||||
<span className="bk-logo__name">Sonnenschein</span>
|
||||
<span className="bk-logo__zusatz">Bäckerei · seit 1987</span>
|
||||
</span>
|
||||
</div>
|
||||
{desktop ? (
|
||||
<>
|
||||
<div className="bk-menue">
|
||||
{MENUE.map((m) => (
|
||||
<span key={m}>{m}</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="bk-knopf bk-knopf--dunkel bk-knopf--klein">Vorbestellen</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="zt-burger">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="zt-held">
|
||||
<div className="zt-held__text">
|
||||
<div className="zt-pille" data-offen={status.offen ? "ja" : "nein"}>
|
||||
<i className="zt-pille__punkt" />
|
||||
{/* der Text wechselt nur, wenn sich der Status ändert; mit Bewegung blendet er dann neu ein */}
|
||||
<span key={status.text} className="zt-pille__text">
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bk-held__titel zt-titel">
|
||||
Frisch gebacken.
|
||||
<br />
|
||||
<em>Jeden Morgen.</em>
|
||||
</div>
|
||||
<p className="bk-held__lead">
|
||||
Brot, Brötchen und Kuchen aus unserer Backstube in der Altstadt – mit Sauerteig, viel Zeit und Mehl aus der Region.
|
||||
</p>
|
||||
{desktop ? (
|
||||
<div className="bk-held__knoepfe">
|
||||
<span className="bk-knopf bk-knopf--dunkel">
|
||||
Sortiment ansehen <Pfeil />
|
||||
</span>
|
||||
<span className="bk-knopf bk-knopf--rand">Torte bestellen</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{desktop ? (
|
||||
<div className="bk-held__bild">
|
||||
<img src={FOTO} width={600} height={556} alt="" decoding="async" />
|
||||
<Karte />
|
||||
<Stempel />
|
||||
</div>
|
||||
) : (
|
||||
<Karte />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{desktop ? (
|
||||
<div className="bk-band">
|
||||
{BANDWOERTER.map((w) => (
|
||||
<span key={w}>{w}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZeitenSeite;
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAblauf } from "../gemeinsam";
|
||||
|
||||
/**
|
||||
* Ablauf der Station „Öffnungszeiten live“ (Bäckerei Sonnenschein), Spezifikation 10.
|
||||
* Reine Logik, kein CSS und keine Adresse: die Komponenten werden später per React.lazy nachgeladen.
|
||||
*
|
||||
* Geschichte (Demo-Datum: Montag 5. Oktober 2026, die Uhr zählt Minuten ab Mo 00:00):
|
||||
* start (kurz Ruhe bei 05:50, „Geschlossen · öffnet heute um 6 Uhr“)
|
||||
* -> lauf (die Uhr läuft in Zehn-Minuten-Schritten bis 18:10; um 06:00 springt der Status auf „Jetzt geöffnet“,
|
||||
* um 18:00 auf „Geschlossen · öffnet morgen um 6 Uhr“)
|
||||
* -> tippen (auf dem Handy gleitet der Finger zu „Hinweis-Leiste anlegen“ und tippt)
|
||||
* -> leiste (die Hinweis-Leiste „Betriebsurlaub 12.–26. Oktober“ gleitet auf der Website ein, bleibt stehen) -> von vorn.
|
||||
*
|
||||
* Bedienen: Wer den Uhr-Regler anfasst (`setzeUhr(minute)`), stoppt den Ablauf. Die Demo zeigt dann den ganzen Stand
|
||||
* (mit Leiste) bei der gewählten Uhrzeit, ohne Timer. `setzeUhr(null)` gibt an den Ablauf zurück (von vorn).
|
||||
*/
|
||||
|
||||
export type ZeitenPhase = "start" | "lauf" | "tippen" | "leiste";
|
||||
|
||||
export type ZeitenStatus = {
|
||||
offen: boolean;
|
||||
/** Langtext der Status-Pille, z. B. „Jetzt geöffnet · schließt um 18 Uhr“ (nie „24“). */
|
||||
text: string;
|
||||
/** Kurztext: „Geöffnet“ oder „Geschlossen“. */
|
||||
kurz: string;
|
||||
};
|
||||
|
||||
export type ZeitenZustand = {
|
||||
phase: ZeitenPhase;
|
||||
/** Minuten ab Montag, 5. Oktober 2026, 00:00. */
|
||||
uhr: number;
|
||||
status: ZeitenStatus;
|
||||
/** Ob die Hinweis-Leiste auf der Website steht (Phase „leiste“, im Endzustand und beim Bedienen). */
|
||||
leiste: boolean;
|
||||
/**
|
||||
* Zähler der Tipps für DemoZeiger: 0 solange der Finger nur gleitet oder nicht zu sehen ist, ab dem Tipp eine je
|
||||
* Durchlauf wachsende Zahl (so erscheint die Klickwelle in jeder Runde neu). Der Finger ist nur in Phase „tippen“ zu sehen.
|
||||
*/
|
||||
klick: number;
|
||||
/** true = der Regler wurde angefasst: der Ablauf steht, die Uhr zeigt den gewählten Wert. */
|
||||
bedient: boolean;
|
||||
/** true = der Automatiklauf spielt gerade (nicht beim Bedienen, nicht ohne Bewegung). */
|
||||
laeuft: boolean;
|
||||
/** true = keine Bewegung erlaubt: die Demo zeigt ihren Endzustand (18:10 mit Leiste). */
|
||||
statisch: boolean;
|
||||
/** Uhr setzen (Regler bedient) oder mit `null` zurück an den Ablauf geben. Die Funktion bleibt über Renderings gleich. */
|
||||
setzeUhr: (min: number | null) => void;
|
||||
};
|
||||
|
||||
// ── Konstanten ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Reglerbereich in Minuten ab Mo 00:00: 05:00 bis 22:00 in Schritten von 10 Minuten. */
|
||||
export const ZEIT_MIN = 300;
|
||||
export const ZEIT_MAX = 1320;
|
||||
export const ZEIT_SCHRITT = 10;
|
||||
/** Uhrzeit, mit der der Automatiklauf beginnt (05:50) und bei der er endet (18:10). */
|
||||
export const START_UHR = 350;
|
||||
export const ENDE_UHR = 1090;
|
||||
|
||||
/** Text der Hinweis-Leiste (der Demo-Tag liegt im 7-Tage-Vorlauf des Urlaubs). */
|
||||
export const HINWEIS_TEXT = "Betriebsurlaub 12.–26. Oktober";
|
||||
|
||||
/**
|
||||
* Mitte des Knopfes „Hinweis-Leiste anlegen“ im Portal-Screenshot phone-12-zeiten (Designpunkte der Handy-Fläche 402 x 874;
|
||||
* gemessen 135,3 / 487,9, gerundet). Die Bühne (Kundenportal.tsx, HINWEIS_PUNKT) übergibt denselben Wert als Prop `punkt`.
|
||||
*/
|
||||
export const HINWEIS_ANLEGEN_PUNKT = { x: 135, y: 488 } as const;
|
||||
|
||||
// ── Status: Bäckerei Sonnenschein, Montag 6 bis 18 Uhr ──────────────────────────
|
||||
|
||||
const OEFFNET = 6 * 60;
|
||||
const SCHLIESST = 18 * 60;
|
||||
|
||||
const ZU_HEUTE: ZeitenStatus = Object.freeze({ offen: false, text: "Geschlossen · öffnet heute um 6 Uhr", kurz: "Geschlossen" });
|
||||
const OFFEN: ZeitenStatus = Object.freeze({ offen: true, text: "Jetzt geöffnet · schließt um 18 Uhr", kurz: "Geöffnet" });
|
||||
const ZU_MORGEN: ZeitenStatus = Object.freeze({ offen: false, text: "Geschlossen · öffnet morgen um 6 Uhr", kurz: "Geschlossen" });
|
||||
|
||||
/** Status am Montag (Minute 0 bis 1439 ab Mo 00:00): vor 06:00 zu, bis 17:59 offen, ab 18:00 zu bis morgen früh. */
|
||||
export const statusFuer = (minute: number): ZeitenStatus =>
|
||||
minute < OEFFNET ? ZU_HEUTE : minute < SCHLIESST ? OFFEN : ZU_MORGEN;
|
||||
|
||||
/** Minuten ab Mitternacht als „05:50“ (die Stunde läuft 00 bis 23, nie „24:00“). */
|
||||
export const formatUhr = (min: number): string => {
|
||||
const tag = ((Math.round(min) % 1440) + 1440) % 1440;
|
||||
return `${String(Math.floor(tag / 60)).padStart(2, "0")}:${String(tag % 60).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// ── Schritte ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Alle Dauern sind Vielfache von 50 ms (die Tests spulen in 50-ms-Schritten vor). */
|
||||
const START_HALTEN = 1000;
|
||||
/** Dauer eines Uhr-Schrittes (10 Minuten) im Lauf. */
|
||||
const TAKT = 100;
|
||||
/** Wie lange 18:10 („Geschlossen · öffnet morgen um 6 Uhr“) stehen bleibt, bevor der Finger kommt. */
|
||||
const ENDE_HALTEN = 1500;
|
||||
/** Der Finger gleitet zum Knopf (Phase „tippen“), dann folgt der Tipp (ebenfalls Phase „tippen“). */
|
||||
const GLEITEN = 1000;
|
||||
const TIPPEN = 450;
|
||||
/** Die Leiste gleitet ein und bleibt stehen. */
|
||||
const LEISTE_HALTEN = 5000;
|
||||
|
||||
type Schritt = { phase: ZeitenPhase; uhr: number; dauer: number; tipp?: boolean };
|
||||
|
||||
const SCHRITTE: readonly Schritt[] = [
|
||||
{ phase: "start", uhr: START_UHR, dauer: START_HALTEN },
|
||||
...Array.from({ length: (ENDE_UHR - START_UHR) / ZEIT_SCHRITT }, (_, i): Schritt => {
|
||||
const uhr = START_UHR + (i + 1) * ZEIT_SCHRITT;
|
||||
return { phase: "lauf", uhr, dauer: uhr === ENDE_UHR ? ENDE_HALTEN : TAKT };
|
||||
}),
|
||||
{ phase: "tippen", uhr: ENDE_UHR, dauer: GLEITEN },
|
||||
{ phase: "tippen", uhr: ENDE_UHR, dauer: TIPPEN, tipp: true },
|
||||
{ phase: "leiste", uhr: ENDE_UHR, dauer: LEISTE_HALTEN },
|
||||
];
|
||||
const DAUERN = SCHRITTE.map((s) => s.dauer);
|
||||
const TIPP_SCHRITT = SCHRITTE.findIndex((s) => s.tipp);
|
||||
|
||||
/** Gesamtdauer eines Durchlaufs in Millisekunden (für Tests und die Feinabstimmung der Bühne). */
|
||||
export const ZEITEN_RUNDE_MS = DAUERN.reduce((a, b) => a + b, 0);
|
||||
|
||||
/** Anfangszustand (Phase „start“, 05:50, ohne Leiste): die Bühne zeigt die Website damit sofort, bevor der Taktgeber geladen ist. */
|
||||
export const ZEITEN_START: ZeitenZustand = {
|
||||
phase: "start",
|
||||
uhr: START_UHR,
|
||||
status: statusFuer(START_UHR),
|
||||
leiste: false,
|
||||
klick: 0,
|
||||
bedient: false,
|
||||
laeuft: false,
|
||||
statisch: false,
|
||||
setzeUhr: () => {},
|
||||
};
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Taktgeber der Station. `laeuft` = Station aktiv, im Bild, Tab sichtbar, Bewegung erlaubt.
|
||||
* Läuft sie nicht, steht die Demo bei „start“ (05:50, nichts bewegt sich, kein Timer); eine Bedienung verfällt dann.
|
||||
* Mit `statisch` (keine Bewegung erlaubt) zeigt sie sofort den Endzustand (18:10 mit Leiste), ohne Timer;
|
||||
* der Regler bleibt auch dann bedienbar (eine Eingabe ist keine Animation).
|
||||
*/
|
||||
export const useZeitenAblauf = (laeuft: boolean, statisch: boolean): ZeitenZustand => {
|
||||
const [manuell, setManuell] = useState<number | null>(null);
|
||||
// Eine Bedienung gilt nur, solange die Station läuft (oder statisch steht); sonst verfällt sie
|
||||
const gewaehlt = laeuft || statisch ? manuell : null;
|
||||
const spielt = laeuft && !statisch && gewaehlt === null;
|
||||
const { schritt, runde } = useAblauf(spielt, DAUERN, true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!laeuft && !statisch && manuell !== null) setManuell(null);
|
||||
}, [laeuft, statisch, manuell]);
|
||||
|
||||
const setzeUhr = useCallback((min: number | null) => {
|
||||
if (min === null) {
|
||||
setManuell(null);
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(min)) return;
|
||||
setManuell(Math.min(ZEIT_MAX, Math.max(ZEIT_MIN, Math.round(min))));
|
||||
}, []);
|
||||
|
||||
return useMemo<ZeitenZustand>(() => {
|
||||
if (statisch || gewaehlt !== null) {
|
||||
// Endzustand bzw. bedient: die Demo steht, die Leiste ist da
|
||||
const uhr = gewaehlt ?? ENDE_UHR;
|
||||
return {
|
||||
phase: "leiste",
|
||||
uhr,
|
||||
status: statusFuer(uhr),
|
||||
leiste: true,
|
||||
klick: 0,
|
||||
bedient: gewaehlt !== null,
|
||||
laeuft: false,
|
||||
statisch,
|
||||
setzeUhr,
|
||||
};
|
||||
}
|
||||
const aktuell = SCHRITTE[spielt ? Math.min(schritt, SCHRITTE.length - 1) : 0];
|
||||
return {
|
||||
phase: aktuell.phase,
|
||||
uhr: aktuell.uhr,
|
||||
status: statusFuer(aktuell.uhr),
|
||||
leiste: aktuell.phase === "leiste",
|
||||
klick: spielt && schritt >= TIPP_SCHRITT ? runde + 1 : 0,
|
||||
bedient: false,
|
||||
laeuft: spielt,
|
||||
statisch: false,
|
||||
setzeUhr,
|
||||
};
|
||||
}, [statisch, gewaehlt, spielt, schritt, runde, setzeUhr]);
|
||||
};
|
||||
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* Station „Öffnungszeiten live“: Beispiel-Website der Bäckerei Sonnenschein mit großer Status-Pille (.zt-*) und der Uhr-Regler
|
||||
* unter dem MacBook (.zr-*). Grundlage ist baeckerei.css (.bk-seite mit den Variablen --bk-*, Logo, Menü, Knöpfe); die Seite hat
|
||||
* ein eigenes Wurzelelement ohne .bk-inhalt, sonst liefe sie mit --kp-v (Scrollfortschritt der Live-Vorschau) davon.
|
||||
* Desktop 1512 x 893, Handy 402 x 874 (oben 59 / unten 34 frei).
|
||||
* Im MacBook liegt das iPhone über der Fläche ab etwa x 1150 und y 410: dort steht nichts Wichtiges (Pille und Öffnungszeiten
|
||||
* links davon, die Hinweis-Leiste oben, oberhalb von y 410). Größen in Designpunkten: das MacBook verkleinert auf etwa 46 %,
|
||||
* darum Pille ≥ 30 und Leiste ≥ 24.
|
||||
* Grundzustand statisch; Bewegung nur transform/opacity hinter prefers-reduced-motion: no-preference.
|
||||
*/
|
||||
|
||||
.zt-seite {
|
||||
--zt-leiste-h: 64px;
|
||||
--zt-offen: #3f5e2c;
|
||||
--zt-offen-punkt: #4c9a3c;
|
||||
--zt-offen-ring: #dcebd3;
|
||||
--zt-zu: #8a3a12;
|
||||
--zt-zu-punkt: #c4622d;
|
||||
--zt-zu-ring: #f4dccb;
|
||||
}
|
||||
|
||||
/* ── Hinweis-Leiste: gleitet oben ein, der Inhalt rückt per transform nach ─────────────────────────── */
|
||||
|
||||
.zt-leiste {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
background: var(--bk-tinte);
|
||||
color: #f6ead6;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.zt-leiste__punkt {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--bk-sonne);
|
||||
box-shadow: 0 0 0 5px rgb(242 181 68 / 0.28);
|
||||
}
|
||||
.zt-inhalt {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.zt-seite[data-phase="leiste"] .zt-leiste {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.zt-seite[data-phase="leiste"] .zt-inhalt {
|
||||
transform: translateY(var(--zt-leiste-h));
|
||||
}
|
||||
|
||||
/* ── Start: Pille, Titel, Öffnungszeiten ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.zt-held {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 560px;
|
||||
gap: 56px;
|
||||
align-items: center;
|
||||
height: 640px;
|
||||
padding: 0 88px;
|
||||
}
|
||||
.zt-held__text {
|
||||
min-width: 0;
|
||||
}
|
||||
.zt-pille {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 28px 12px 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--bk-papier);
|
||||
border: 2px solid var(--bk-linie);
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
color: var(--zt-offen);
|
||||
}
|
||||
.zt-pille__punkt {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--zt-offen-punkt);
|
||||
box-shadow: 0 0 0 6px var(--zt-offen-ring);
|
||||
}
|
||||
.zt-pille[data-offen="nein"] {
|
||||
color: var(--zt-zu);
|
||||
}
|
||||
.zt-pille[data-offen="nein"] .zt-pille__punkt {
|
||||
background: var(--zt-zu-punkt);
|
||||
box-shadow: 0 0 0 6px var(--zt-zu-ring);
|
||||
}
|
||||
.zt-titel {
|
||||
margin-top: 30px;
|
||||
}
|
||||
.zt-karte {
|
||||
position: absolute;
|
||||
left: -56px;
|
||||
bottom: 36px;
|
||||
width: 300px;
|
||||
padding: 18px 22px 16px;
|
||||
border-radius: 22px;
|
||||
background: var(--bk-papier);
|
||||
box-shadow: 0 24px 50px -24px rgb(60 30 5 / 0.45);
|
||||
}
|
||||
.zt-karte .bk-dach {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.zt-zeile {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 7px 10px;
|
||||
margin: 0 -10px;
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
color: var(--bk-tinte-leise);
|
||||
}
|
||||
.zt-zeile.is-heute {
|
||||
background: var(--bk-sand);
|
||||
color: var(--bk-tinte);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Handy (402 x 874): Statusleiste oben 59 frei, Leiste darunter ─────────────────────────────────── */
|
||||
|
||||
.zt-seite[data-variante="handy"] {
|
||||
--zt-leiste-h: 52px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-leiste {
|
||||
top: 59px;
|
||||
height: 52px;
|
||||
gap: 10px;
|
||||
font-size: 17px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-leiste__punkt {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
box-shadow: 0 0 0 4px rgb(242 181 68 / 0.28);
|
||||
}
|
||||
/* die oberen 59 px überdeckt die Statusleiste des iPhones (heller Text): ein dunkler Streifen macht Uhrzeit und Symbole lesbar */
|
||||
.zt-seite[data-variante="handy"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 59px;
|
||||
z-index: 4;
|
||||
background: var(--bk-tinte);
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-inhalt {
|
||||
top: 59px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-kopf {
|
||||
height: 64px;
|
||||
padding: 0 20px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .bk-logo {
|
||||
gap: 10px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .bk-logo__zeichen {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .bk-logo__name {
|
||||
font-size: 27px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .bk-logo__zusatz {
|
||||
margin-top: 3px;
|
||||
font-size: 9.5px;
|
||||
}
|
||||
.zt-burger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
width: 26px;
|
||||
}
|
||||
.zt-burger i {
|
||||
display: block;
|
||||
height: 2.5px;
|
||||
border-radius: 2px;
|
||||
background: var(--bk-tinte);
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-held {
|
||||
display: block;
|
||||
height: auto;
|
||||
padding: 22px 20px 0;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-pille {
|
||||
gap: 11px;
|
||||
max-width: 100%;
|
||||
padding: 9px 18px 9px 15px;
|
||||
border-width: 1.5px;
|
||||
font-size: 17px;
|
||||
line-height: 1.25;
|
||||
white-space: normal;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-pille__punkt {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
box-shadow: 0 0 0 4px var(--zt-offen-ring);
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-pille[data-offen="nein"] .zt-pille__punkt {
|
||||
box-shadow: 0 0 0 4px var(--zt-zu-ring);
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-titel {
|
||||
margin: 22px 0 14px;
|
||||
font-size: 62px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .bk-held__lead {
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-karte {
|
||||
position: static;
|
||||
width: auto;
|
||||
margin-top: 26px;
|
||||
border: 1px solid var(--bk-linie);
|
||||
box-shadow: none;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-zeile {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ── Uhr-Regler unter dem MacBook (normaler Seitenmaßstab, Seitentoken, hell und dunkel) ───────────── */
|
||||
|
||||
.zr {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
padding: 10px 16px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-alt);
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
}
|
||||
.zr *,
|
||||
.zr *::before,
|
||||
.zr *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.zr__kopf {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.zr__titel {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.zr__uhr {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--fg);
|
||||
}
|
||||
.zr__regler {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
accent-color: var(--accent-text);
|
||||
touch-action: pan-y;
|
||||
cursor: pointer;
|
||||
}
|
||||
.zr__regler:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.zr__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 0;
|
||||
min-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.zr__punkt {
|
||||
flex: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #3d9a4a;
|
||||
}
|
||||
.zr__status[data-offen="nein"] .zr__punkt {
|
||||
background: #c4622d;
|
||||
}
|
||||
.zr--kompakt {
|
||||
max-width: 320px;
|
||||
padding: 10px 14px 12px;
|
||||
}
|
||||
|
||||
/* ── Bewegung (nur transform und opacity, nur ohne reduzierte Bewegung) ────────────────────────────── */
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.zt-leiste {
|
||||
transition:
|
||||
transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
opacity 400ms ease;
|
||||
}
|
||||
.zt-inhalt {
|
||||
transition: transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.zt-pille__text {
|
||||
animation: zt-wechsel 420ms ease-out both;
|
||||
}
|
||||
}
|
||||
@keyframes zt-wechsel {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,7 @@
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
/* sehr niedrige Fenster: nur die aktive Funktion zeigt ihre Unterschrift (acht Stationen brauchen sonst mehr Platz) */
|
||||
/* sehr niedrige Fenster: nur die aktive Funktion zeigt ihre Unterschrift (neun Stationen brauchen sonst mehr Platz) */
|
||||
@media (max-height: 720px) {
|
||||
/* Oberkante bleibt stehen, wenn die Unterschriften wechseln (sonst würde die zentrierte Liste springen) */
|
||||
.kp-liste {
|
||||
@@ -368,16 +368,22 @@
|
||||
display: grid;
|
||||
}
|
||||
.kp-diagnose,
|
||||
.kp-regler,
|
||||
.kp-hinweis {
|
||||
grid-area: 1 / 1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.kp-diagnose[data-an="ja"],
|
||||
.kp-regler[data-an="ja"],
|
||||
.kp-hinweis[data-an="ja"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* Der Regler (Öffnungszeiten live) ist nie höher als die Diagnose, damit die Zelle beim Wechsel nicht springt */
|
||||
.kp-regler {
|
||||
align-self: start;
|
||||
}
|
||||
/* Hinweiskarte im Stil der Diagnose (gleiche Linie, gleiches Padding) */
|
||||
.kp-hinweis {
|
||||
align-self: start;
|
||||
@@ -408,6 +414,7 @@
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.kp-diagnose,
|
||||
.kp-regler,
|
||||
.kp-hinweis,
|
||||
.kp-beispiel {
|
||||
transition: opacity var(--dur-enter) var(--ease);
|
||||
@@ -539,7 +546,8 @@
|
||||
color: var(--fg-muted);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.kp-karte__diagnose {
|
||||
.kp-karte__diagnose,
|
||||
.kp-karte__regler {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin-top: 1rem;
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -176,14 +176,14 @@ describe("Quelltext", () => {
|
||||
});
|
||||
|
||||
it("lädt nichts von fremden Servern", () => {
|
||||
for (const datei of ["chat/ChatEbene.tsx", "chat/ChatSeite.tsx", "chat/ChatHandy.tsx", "chat/chat.css", "baeckerei/BaeckereiSeite.tsx", "baeckerei/baeckerei.css"]) {
|
||||
for (const datei of ["chat/ChatEbene.tsx", "chat/ChatSeite.tsx", "chat/ChatHandy.tsx", "chat/chat.css", "baeckerei/BaeckereiSeite.tsx", "baeckerei/teile.tsx", "baeckerei/baeckerei.css", "zeiten/ZeitenSeite.tsx", "zeiten/ZeitenHandy.tsx", "zeiten/ZeitenRegler.tsx", "zeiten/zeiten.css"]) {
|
||||
expect(quelle(datei), datei).not.toMatch(/https?:\/\//);
|
||||
expect(quelle(datei), datei).not.toMatch(/googleapis|gstatic|unsplash/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("bewegt nur transform und opacity und nur ohne reduzierte Bewegung", () => {
|
||||
for (const datei of ["chat/chat.css", "baeckerei/baeckerei.css"]) {
|
||||
for (const datei of ["chat/chat.css", "baeckerei/baeckerei.css", "zeiten/zeiten.css"]) {
|
||||
const css = quelle(datei).replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
for (const treffer of css.matchAll(/@keyframes[^{]+\{([\s\S]*?)\n\}/g)) {
|
||||
const eigenschaften = [...treffer[1].matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1]);
|
||||
|
||||
@@ -0,0 +1,905 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, renderHook, act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { Logo, Stempel, Pfeil } from "@/components/home/demos/baeckerei/teile";
|
||||
import ZeitenSeite from "@/components/home/demos/zeiten/ZeitenSeite";
|
||||
import ZeitenHandy from "@/components/home/demos/zeiten/ZeitenHandy";
|
||||
import ZeitenRegler from "@/components/home/demos/zeiten/ZeitenRegler";
|
||||
import { ZeitenSteuerung } from "@/components/home/demos/steuerung";
|
||||
import Kundenportal from "@/components/home/Kundenportal";
|
||||
import {
|
||||
useZeitenAblauf,
|
||||
statusFuer,
|
||||
formatUhr,
|
||||
ZEIT_MIN,
|
||||
ZEIT_MAX,
|
||||
ZEIT_SCHRITT,
|
||||
START_UHR,
|
||||
ENDE_UHR,
|
||||
HINWEIS_TEXT,
|
||||
HINWEIS_ANLEGEN_PUNKT,
|
||||
ZEITEN_START,
|
||||
ZEITEN_RUNDE_MS,
|
||||
type ZeitenZustand,
|
||||
} from "@/components/home/demos/zeiten/ablauf";
|
||||
|
||||
// Spezifikation 2026-10-04-oeffnungszeiten-live, Abschnitt 10 (Relaunch-Station): Bäckerei Sonnenschein, Montag 5. Oktober 2026
|
||||
// (Mo bis Fr 6 bis 18 Uhr). Der Ablauf ist reine Logik: Uhr, Status, Hinweis-Leiste.
|
||||
|
||||
const OFFEN = "Jetzt geöffnet · schließt um 18 Uhr";
|
||||
const ZU_MORGEN = "Geschlossen · öffnet morgen um 6 Uhr";
|
||||
const ZU_HEUTE = "Geschlossen · öffnet heute um 6 Uhr";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/** Zeit in kleinen Schritten vorspulen: jeder Schritt der Demo setzt seinen Timer erst nach dem Rendern. */
|
||||
const vor = (ms: number) => {
|
||||
for (let rest = ms; rest > 0; rest -= 50) {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(Math.min(50, rest));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const ablauf = (laeuft: boolean, statisch: boolean) =>
|
||||
renderHook(({ l, s }) => useZeitenAblauf(l, s), { initialProps: { l: laeuft, s: statisch } });
|
||||
|
||||
describe("statusFuer und formatUhr", () => {
|
||||
it("statusFuer deckt jede Minute ab, nie „24“", () => {
|
||||
for (let m = ZEIT_MIN; m <= ZEIT_MAX; m++) {
|
||||
const s = statusFuer(m);
|
||||
expect(typeof s.offen, `Minute ${m}`).toBe("boolean");
|
||||
expect(s.text, `Minute ${m}`).not.toMatch(/24/);
|
||||
expect(s.text.length, `Minute ${m}`).toBeGreaterThan(0);
|
||||
expect(s.kurz, `Minute ${m}`).toBe(s.offen ? "Geöffnet" : "Geschlossen");
|
||||
expect(s.text, `Minute ${m}`).toContain(s.offen ? "Jetzt geöffnet" : "Geschlossen");
|
||||
}
|
||||
// auch außerhalb des Reglerbereichs (ganzer Montag) nie „24“
|
||||
for (let m = 0; m < 1440; m++) expect(statusFuer(m).text).not.toMatch(/24/);
|
||||
});
|
||||
|
||||
it("05:50 geschlossen, 06:00 geöffnet, 18:00 geschlossen · öffnet morgen um 6 Uhr", () => {
|
||||
expect(statusFuer(350)).toEqual({ offen: false, text: ZU_HEUTE, kurz: "Geschlossen" });
|
||||
expect(statusFuer(359).offen).toBe(false);
|
||||
expect(statusFuer(360)).toEqual({ offen: true, text: OFFEN, kurz: "Geöffnet" });
|
||||
expect(statusFuer(1079)).toEqual({ offen: true, text: OFFEN, kurz: "Geöffnet" });
|
||||
expect(statusFuer(1080)).toEqual({ offen: false, text: ZU_MORGEN, kurz: "Geschlossen" });
|
||||
expect(statusFuer(1090).text).toBe(ZU_MORGEN);
|
||||
expect(statusFuer(ZEIT_MIN).text).toBe(ZU_HEUTE);
|
||||
expect(statusFuer(ZEIT_MAX).text).toBe(ZU_MORGEN);
|
||||
});
|
||||
|
||||
it("nutzt dasselbe Trennzeichen „·“ (U+00B7) wie die Portal-Texte", () => {
|
||||
for (const m of [ZEIT_MIN, START_UHR, 360, 700, 1080, ENDE_UHR, ZEIT_MAX]) {
|
||||
expect(statusFuer(m).text.split(" · ")).toHaveLength(2);
|
||||
expect(statusFuer(m).text).toContain("·");
|
||||
}
|
||||
});
|
||||
|
||||
it("formatUhr schreibt Minuten ab Mitternacht als HH:MM, nie „24:00“", () => {
|
||||
expect(formatUhr(350)).toBe("05:50");
|
||||
expect(formatUhr(360)).toBe("06:00");
|
||||
expect(formatUhr(1090)).toBe("18:10");
|
||||
expect(formatUhr(ZEIT_MIN)).toBe("05:00");
|
||||
expect(formatUhr(ZEIT_MAX)).toBe("22:00");
|
||||
expect(formatUhr(0)).toBe("00:00");
|
||||
expect(formatUhr(1439)).toBe("23:59");
|
||||
expect(formatUhr(1440)).toBe("00:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Konstanten", () => {
|
||||
it("haben die Werte der Spezifikation und liegen im Bereich des Reglers", () => {
|
||||
expect([ZEIT_MIN, ZEIT_MAX, ZEIT_SCHRITT, START_UHR, ENDE_UHR]).toEqual([300, 1320, 10, 350, 1090]);
|
||||
expect(HINWEIS_TEXT).toBe("Betriebsurlaub 12.–26. Oktober");
|
||||
expect(HINWEIS_TEXT).toContain("–");
|
||||
expect(HINWEIS_TEXT).not.toMatch(/24/);
|
||||
for (const u of [START_UHR, ENDE_UHR]) {
|
||||
expect(u).toBeGreaterThanOrEqual(ZEIT_MIN);
|
||||
expect(u).toBeLessThanOrEqual(ZEIT_MAX);
|
||||
expect((u - ZEIT_MIN) % ZEIT_SCHRITT).toBe(0);
|
||||
}
|
||||
expect(HINWEIS_ANLEGEN_PUNKT.x).toBeGreaterThan(0);
|
||||
expect(HINWEIS_ANLEGEN_PUNKT.y).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("ZEITEN_START ist der Anfangszustand vor dem Laden des Taktgebers", () => {
|
||||
expect(ZEITEN_START).toMatchObject({
|
||||
phase: "start",
|
||||
uhr: START_UHR,
|
||||
status: statusFuer(START_UHR),
|
||||
leiste: false,
|
||||
klick: 0,
|
||||
bedient: false,
|
||||
laeuft: false,
|
||||
statisch: false,
|
||||
});
|
||||
expect(() => ZEITEN_START.setzeUhr(700)).not.toThrow();
|
||||
expect(ZEITEN_START.uhr).toBe(START_UHR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useZeitenAblauf", () => {
|
||||
it("Ablauf: Uhr läuft, Finger tippt, Leiste erscheint", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = ablauf(true, false);
|
||||
expect(result.current).toMatchObject({
|
||||
phase: "start",
|
||||
uhr: START_UHR,
|
||||
leiste: false,
|
||||
klick: 0,
|
||||
bedient: false,
|
||||
laeuft: true,
|
||||
statisch: false,
|
||||
});
|
||||
expect(result.current.status.text).toBe(ZU_HEUTE);
|
||||
|
||||
const phasen: string[] = [result.current.phase];
|
||||
let letzteUhr = result.current.uhr;
|
||||
let hoechsteUhr = letzteUhr;
|
||||
let gesehenOffen = false;
|
||||
let gesehenZuMorgen = false;
|
||||
let leisteVorLeistePhase = false;
|
||||
let klickVorTippPhase = false;
|
||||
let klickInTippen = 0;
|
||||
let leisteSichtbarAb = -1;
|
||||
for (let t = 0; t < ZEITEN_RUNDE_MS - 50; t += 50) {
|
||||
vor(50);
|
||||
const z = result.current;
|
||||
if (phasen[phasen.length - 1] !== z.phase) phasen.push(z.phase);
|
||||
// die Uhr läuft nur vorwärts, in Schritten von 10 Minuten
|
||||
expect(z.uhr).toBeGreaterThanOrEqual(letzteUhr);
|
||||
expect((z.uhr - START_UHR) % ZEIT_SCHRITT).toBe(0);
|
||||
letzteUhr = z.uhr;
|
||||
hoechsteUhr = Math.max(hoechsteUhr, z.uhr);
|
||||
expect(z.status).toEqual(statusFuer(z.uhr));
|
||||
if (z.status.text === OFFEN) gesehenOffen = true;
|
||||
if (z.status.text === ZU_MORGEN) gesehenZuMorgen = true;
|
||||
if (z.leiste && z.phase !== "leiste") leisteVorLeistePhase = true;
|
||||
if (z.klick > 0 && z.phase !== "tippen" && z.phase !== "leiste") klickVorTippPhase = true;
|
||||
if (z.phase === "tippen") klickInTippen = Math.max(klickInTippen, z.klick);
|
||||
if (z.leiste && leisteSichtbarAb < 0) leisteSichtbarAb = t;
|
||||
expect(z.bedient).toBe(false);
|
||||
expect(z.statisch).toBe(false);
|
||||
}
|
||||
expect(phasen).toEqual(["start", "lauf", "tippen", "leiste"]);
|
||||
expect(hoechsteUhr).toBe(ENDE_UHR);
|
||||
expect(gesehenOffen).toBe(true);
|
||||
expect(gesehenZuMorgen).toBe(true);
|
||||
expect(leisteVorLeistePhase).toBe(false);
|
||||
expect(klickVorTippPhase).toBe(false);
|
||||
// der Finger gleitet erst (klick 0) und tippt dann (klick wächst auf 1)
|
||||
expect(klickInTippen).toBe(1);
|
||||
expect(leisteSichtbarAb).toBeGreaterThan(0);
|
||||
// am Ende steht die Leiste, die Uhr zeigt 18:10
|
||||
expect(result.current).toMatchObject({ phase: "leiste", uhr: ENDE_UHR, leiste: true, klick: 1 });
|
||||
expect(result.current.status.text).toBe(ZU_MORGEN);
|
||||
});
|
||||
|
||||
it("beginnt nach einer Runde von vorn (Uhr 05:50, Leiste weg, Tipp zählt weiter)", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = ablauf(true, false);
|
||||
vor(ZEITEN_RUNDE_MS);
|
||||
expect(result.current).toMatchObject({ phase: "start", uhr: START_UHR, leiste: false, klick: 0 });
|
||||
expect(result.current.status.text).toBe(ZU_HEUTE);
|
||||
// zweite Runde: der Tipp hat eine neue Zahl (damit die Klickwelle erneut erscheint)
|
||||
const klicks = new Set<number>();
|
||||
for (let t = 0; t < ZEITEN_RUNDE_MS; t += 50) {
|
||||
vor(50);
|
||||
if (result.current.phase === "tippen" && result.current.klick > 0) klicks.add(result.current.klick);
|
||||
}
|
||||
expect([...klicks]).toEqual([2]);
|
||||
});
|
||||
|
||||
it("Bedienen stoppt den Ablauf", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = ablauf(true, false);
|
||||
vor(1000);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
act(() => result.current.setzeUhr(700));
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(result.current).toMatchObject({ uhr: 700, bedient: true, laeuft: false, statisch: false, leiste: true, klick: 0 });
|
||||
expect(result.current.status.text).toBe(OFFEN);
|
||||
// es bewegt sich nichts mehr
|
||||
vor(60_000);
|
||||
expect(result.current.uhr).toBe(700);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
// weiter bedienen: der Status folgt der Uhr
|
||||
act(() => result.current.setzeUhr(1100));
|
||||
expect(result.current.status.text).toBe(ZU_MORGEN);
|
||||
act(() => result.current.setzeUhr(ZEIT_MIN));
|
||||
expect(result.current.status.text).toBe(ZU_HEUTE);
|
||||
expect(result.current.bedient).toBe(true);
|
||||
});
|
||||
|
||||
it("Bedienen mitten im Lauf zeigt sofort den ganzen Stand (mit Leiste), nie halbfertig", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = ablauf(true, false);
|
||||
vor(100);
|
||||
expect(result.current.leiste).toBe(false);
|
||||
act(() => result.current.setzeUhr(500));
|
||||
expect(result.current).toMatchObject({ phase: "leiste", leiste: true, uhr: 500, klick: 0 });
|
||||
});
|
||||
|
||||
it("begrenzt die gesetzte Uhr auf den Bereich des Reglers und ignoriert ungültige Werte", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = ablauf(true, false);
|
||||
act(() => result.current.setzeUhr(99_999));
|
||||
expect(result.current.uhr).toBe(ZEIT_MAX);
|
||||
act(() => result.current.setzeUhr(-5));
|
||||
expect(result.current.uhr).toBe(ZEIT_MIN);
|
||||
act(() => result.current.setzeUhr(700));
|
||||
act(() => result.current.setzeUhr(Number.NaN));
|
||||
expect(result.current.uhr).toBe(700);
|
||||
expect(result.current.bedient).toBe(true);
|
||||
});
|
||||
|
||||
it("setzeUhr(null) gibt zurück an den Ablauf: von vorn bei 05:50, Timer läuft wieder", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = ablauf(true, false);
|
||||
act(() => result.current.setzeUhr(700));
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
act(() => result.current.setzeUhr(null));
|
||||
expect(result.current).toMatchObject({ phase: "start", uhr: START_UHR, leiste: false, klick: 0, bedient: false, laeuft: true });
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
vor(5000);
|
||||
expect(result.current.uhr).toBeGreaterThan(START_UHR);
|
||||
});
|
||||
|
||||
it("steht ohne laeuft still (05:50, kein Timer) und wirft eine Bedienung beim Anhalten weg", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result, rerender } = ablauf(false, false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
vor(60_000);
|
||||
expect(result.current).toMatchObject({ phase: "start", uhr: START_UHR, leiste: false, klick: 0, laeuft: false, bedient: false });
|
||||
|
||||
rerender({ l: true, s: false });
|
||||
act(() => result.current.setzeUhr(700));
|
||||
expect(result.current.bedient).toBe(true);
|
||||
rerender({ l: false, s: false });
|
||||
expect(result.current).toMatchObject({ phase: "start", uhr: START_UHR, leiste: false, bedient: false, laeuft: false });
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
// beim nächsten Start beginnt der Ablauf von vorn
|
||||
rerender({ l: true, s: false });
|
||||
expect(result.current).toMatchObject({ phase: "start", uhr: START_UHR });
|
||||
vor(1000);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("setzt beim Anhalten mitten im Lauf auf den Anfang zurück", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result, rerender } = ablauf(true, false);
|
||||
vor(3000);
|
||||
expect(result.current.phase).toBe("lauf");
|
||||
expect(result.current.uhr).toBeGreaterThan(START_UHR);
|
||||
rerender({ l: false, s: false });
|
||||
expect(result.current).toMatchObject({ phase: "start", uhr: START_UHR, leiste: false, klick: 0 });
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("statisch: Endzustand 18:10 mit Leiste, ohne Timer", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = ablauf(true, true);
|
||||
expect(result.current).toMatchObject({
|
||||
phase: "leiste",
|
||||
uhr: ENDE_UHR,
|
||||
leiste: true,
|
||||
klick: 0,
|
||||
bedient: false,
|
||||
laeuft: false,
|
||||
statisch: true,
|
||||
});
|
||||
expect(result.current.status.text).toBe(ZU_MORGEN);
|
||||
expect(formatUhr(result.current.uhr)).toBe("18:10");
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
vor(30_000);
|
||||
expect(result.current.uhr).toBe(ENDE_UHR);
|
||||
expect(result.current.leiste).toBe(true);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("statisch: der Regler bleibt bedienbar, setzeUhr(null) führt zurück auf 18:10", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = ablauf(false, true);
|
||||
act(() => result.current.setzeUhr(400));
|
||||
expect(result.current).toMatchObject({ uhr: 400, bedient: true, leiste: true, statisch: true, laeuft: false });
|
||||
expect(result.current.status.text).toBe(OFFEN);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
act(() => result.current.setzeUhr(null));
|
||||
expect(result.current).toMatchObject({ uhr: ENDE_UHR, bedient: false, leiste: true });
|
||||
});
|
||||
|
||||
it("setzeUhr behält seine Identität (sonst feuern Effekte der Steuerung bei jedem Takt)", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = ablauf(true, false);
|
||||
const erste = result.current.setzeUhr;
|
||||
vor(2000);
|
||||
expect(result.current.setzeUhr).toBe(erste);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gemeinsame Bäckerei-Teile (teile.tsx)", () => {
|
||||
it("Logo, Stempel und Pfeil sind unverändert nutzbar", () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<Logo />
|
||||
<Stempel />
|
||||
<Pfeil />
|
||||
</>,
|
||||
);
|
||||
const svgs = container.querySelectorAll("svg");
|
||||
expect(svgs).toHaveLength(3);
|
||||
for (const svg of svgs) {
|
||||
expect(svg).toHaveAttribute("aria-hidden", "true");
|
||||
expect(svg).toHaveAttribute("focusable", "false");
|
||||
}
|
||||
expect(container.querySelector("svg.bk-logo__zeichen")).toHaveAttribute("viewBox", "0 0 48 48");
|
||||
expect(container.querySelector("svg.bk-stempel")?.textContent).toContain("HANDWERK · SEIT 1987 · ALTSTADT ·");
|
||||
expect(container.querySelector("svg path[stroke='currentColor']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("zwei Stempel auf einer Seite haben verschiedene Kreisbahn-IDs", () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<Stempel />
|
||||
<Stempel />
|
||||
</>,
|
||||
);
|
||||
const ids = [...container.querySelectorAll("defs path")].map((p) => p.getAttribute("id"));
|
||||
expect(ids).toHaveLength(2);
|
||||
expect(ids[0]).not.toBe(ids[1]);
|
||||
for (const id of ids) expect(container.querySelector(`textPath[href="#${id}"]`)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Quelltext", () => {
|
||||
const quelle = (pfad: string) => readFileSync(`${process.cwd()}/src/components/home/demos/${pfad}`, "utf8");
|
||||
|
||||
it("ablauf.ts importiert kein CSS und keine URL", () => {
|
||||
const text = quelle("zeiten/ablauf.ts");
|
||||
expect(text).not.toMatch(/import\s+["'][^"']+\.css["']|from\s+["'][^"']+\.css["']/);
|
||||
expect(text).not.toMatch(/https?:\/\//);
|
||||
expect(text).not.toMatch(/googleapis|gstatic|unsplash/i);
|
||||
});
|
||||
|
||||
it("neue Dateien halten die Textregeln des Relaunchs ein (kein „24 Stunden“, „WEBklar“, keine Zahlenversprechen)", () => {
|
||||
for (const datei of ["zeiten/ablauf.ts", "baeckerei/teile.tsx"]) {
|
||||
const text = quelle(datei);
|
||||
expect(text, datei).not.toMatch(/24 Stunden|24 h|24h/i);
|
||||
expect(text, datei).not.toContain("Webklar");
|
||||
expect(text, datei).not.toContain("99,9");
|
||||
expect(text, datei).not.toMatch(/https?:\/\//);
|
||||
}
|
||||
});
|
||||
|
||||
it("BaeckereiSeite holt Logo, Stempel und Pfeil aus teile.tsx und definiert sie nicht mehr selbst", () => {
|
||||
const seite = quelle("baeckerei/BaeckereiSeite.tsx");
|
||||
expect(seite).toMatch(/import\s*\{[^}]*\bLogo\b[^}]*\}\s*from\s*"\.\/teile"/);
|
||||
expect(seite).not.toMatch(/const (Logo|Stempel|Pfeil)\s*=/);
|
||||
const teile = quelle("baeckerei/teile.tsx");
|
||||
for (const name of ["Logo", "Stempel", "Pfeil"]) expect(teile).toMatch(new RegExp(`export const ${name}\\s*=`));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Station einbauen (Task 35): Seite, Handy, Regler, Bühne ──────────────────────────────────────────────
|
||||
|
||||
const START: ZeitenZustand = ZEITEN_START;
|
||||
const bei = (uhr: number, extra: Partial<ZeitenZustand> = {}): ZeitenZustand => ({
|
||||
...START,
|
||||
uhr,
|
||||
status: statusFuer(uhr),
|
||||
...extra,
|
||||
});
|
||||
const ENDE: ZeitenZustand = bei(ENDE_UHR, { phase: "leiste", leiste: true, statisch: true });
|
||||
|
||||
const FOKUSSIERBAR = "a, button, input, select, textarea, iframe, summary, [tabindex], [contenteditable]";
|
||||
|
||||
/** Regeln einer CSS-Datei (ohne Kommentare): Selektor -> Deklarationen (Eigenschaft -> Wert). Auch Regeln in @media. */
|
||||
const regeln = (pfad: string) => {
|
||||
const css = readFileSync(`${process.cwd()}/src/components/home/${pfad}`, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const liste: { selektor: string; werte: Record<string, string> }[] = [];
|
||||
for (const m of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
const werte: Record<string, string> = {};
|
||||
for (const d of m[2].split(";")) {
|
||||
const i = d.indexOf(":");
|
||||
if (i > 0) werte[d.slice(0, i).trim()] = d.slice(i + 1).trim();
|
||||
}
|
||||
liste.push({ selektor: m[1].trim().replace(/\s+/g, " "), werte });
|
||||
}
|
||||
return liste;
|
||||
};
|
||||
/** Die erste Regel mit genau diesem Selektor (der Grundzustand steht im Quelltext vor den Abwandlungen). */
|
||||
const regel = (pfad: string, selektor: string) => {
|
||||
const r = regeln(pfad).find((x) => x.selektor === selektor);
|
||||
expect(r, `${pfad}: Regel ${selektor}`).toBeDefined();
|
||||
return r!.werte;
|
||||
};
|
||||
const px = (wert: string | undefined) => {
|
||||
const m = /^(-?[\d.]+)(?:px)?$/.exec(wert ?? "");
|
||||
expect(m, `Wert in px: ${wert}`).not.toBeNull();
|
||||
return Number(m![1]);
|
||||
};
|
||||
|
||||
describe("ZeitenSeite", () => {
|
||||
for (const variante of ["desktop", "handy"] as const) {
|
||||
it(`Start: Pille „Geschlossen · öffnet heute um 6 Uhr“, Wurzel ohne .bk-inhalt, aria-hidden (${variante})`, () => {
|
||||
const { container } = render(<ZeitenSeite zustand={START} variante={variante} />);
|
||||
const wurzel = container.firstElementChild as HTMLElement;
|
||||
expect(wurzel).toHaveClass("bk-seite", "zt-seite");
|
||||
expect(wurzel).toHaveAttribute("aria-hidden", "true");
|
||||
expect(wurzel).toHaveAttribute("data-variante", variante);
|
||||
expect(wurzel).toHaveAttribute("data-phase", "start");
|
||||
expect(wurzel).toHaveAttribute("data-laeuft", "nein");
|
||||
// eigenes Wurzelelement: sonst griffe die Scroll-Verschiebung der Live-Vorschau (--kp-v)
|
||||
expect(container.querySelector(".bk-inhalt")).toBeNull();
|
||||
const pille = container.querySelector(".zt-pille") as HTMLElement;
|
||||
expect(pille).toHaveTextContent(ZU_HEUTE);
|
||||
expect(pille).toHaveAttribute("data-offen", "nein");
|
||||
expect(container.querySelector(".zt-leiste")).toHaveTextContent(HINWEIS_TEXT);
|
||||
});
|
||||
|
||||
it(`Die Pille folgt dem Status: geöffnet um 11:40, danach „öffnet morgen“ (${variante})`, () => {
|
||||
const { container, rerender } = render(<ZeitenSeite zustand={bei(700, { phase: "lauf", laeuft: true })} variante={variante} />);
|
||||
expect(container.querySelector(".zt-pille")).toHaveTextContent(OFFEN);
|
||||
expect(container.querySelector(".zt-pille")).toHaveAttribute("data-offen", "ja");
|
||||
expect(container.firstElementChild).toHaveAttribute("data-laeuft", "ja");
|
||||
rerender(<ZeitenSeite zustand={ENDE} variante={variante} />);
|
||||
expect(container.querySelector(".zt-pille")).toHaveTextContent(ZU_MORGEN);
|
||||
expect(container.querySelector(".zt-pille")).toHaveAttribute("data-offen", "nein");
|
||||
expect(container.firstElementChild).toHaveAttribute("data-phase", "leiste");
|
||||
});
|
||||
|
||||
it(`Ohne Zustand steht die Seite im Anfangszustand da (${variante})`, () => {
|
||||
const { container } = render(<ZeitenSeite variante={variante} />);
|
||||
expect(container.firstElementChild).toHaveAttribute("data-phase", "start");
|
||||
expect(container.querySelector(".zt-pille")).toHaveTextContent(ZU_HEUTE);
|
||||
});
|
||||
|
||||
it(`Öffnungszeiten der Bäckerei: Mo–Fr 6–18, Sa 6–13, So 7–11, heute (Montag) hervorgehoben (${variante})`, () => {
|
||||
const { container } = render(<ZeitenSeite zustand={START} variante={variante} />);
|
||||
const zeilen = Array.from(container.querySelectorAll(".zt-zeile"));
|
||||
expect(zeilen.map((z) => z.textContent)).toEqual(["Mo – Fr6:00 – 18:00 Uhr", "Sa6:00 – 13:00 Uhr", "So7:00 – 11:00 Uhr"]);
|
||||
expect(zeilen.map((z) => z.classList.contains("is-heute"))).toEqual([true, false, false]);
|
||||
});
|
||||
|
||||
it(`Logo und Pfeil kommen aus teile.tsx; keine Bilder mit Alt-Text-Pflicht, keine Links (${variante})`, () => {
|
||||
const { container } = render(<ZeitenSeite zustand={START} variante={variante} />);
|
||||
expect(container.querySelector("svg.bk-logo__zeichen")).not.toBeNull();
|
||||
expect(container.querySelectorAll("a, iframe, video, script, link")).toHaveLength(0);
|
||||
for (const bild of container.querySelectorAll("img")) {
|
||||
expect(bild).toHaveAttribute("alt", "");
|
||||
expect(bild.getAttribute("src")).toMatch(/^\/relaunch\/demos\/baeckerei\/[a-z0-9-]+\.webp$/);
|
||||
expect(existsSync(`${process.cwd()}/public${bild.getAttribute("src")}`), bild.getAttribute("src")!).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("Seite: Pille ≥ 30 pt, Leiste ≥ 24 pt, nicht unter dem iPhone", () => {
|
||||
const datei = "demos/zeiten/zeiten.css";
|
||||
// im MacBook ist alles auf 1512 x 893 gesetzt; das iPhone liegt über der Fläche ab x 1150 und y 410
|
||||
expect(px(regel(datei, ".zt-pille")["font-size"])).toBeGreaterThanOrEqual(30);
|
||||
const leiste = regel(datei, ".zt-leiste");
|
||||
expect(px(leiste["font-size"])).toBeGreaterThanOrEqual(24);
|
||||
// Leiste: oben über die ganze Breite und höchstens bis y 410 (also links von x 1150 oder oberhalb y 410)
|
||||
expect(px(leiste.top) + px(leiste.height)).toBeLessThanOrEqual(410);
|
||||
expect(leiste.left).toBe("0");
|
||||
expect(leiste.right).toBe("0");
|
||||
// Pille: steht in der linken Spalte des Starts, die vor x 1150 endet
|
||||
const held = regel(datei, ".zt-held");
|
||||
const [, rechts] = held["grid-template-columns"].split(" ");
|
||||
const linksEnde = 1512 - px(held.padding.split(" ")[1]) - px(rechts) - px(held.gap);
|
||||
expect(linksEnde).toBeLessThanOrEqual(1150);
|
||||
// Öffnungszeiten-Karte rechts daneben: ihre rechte Kante bleibt links vom iPhone
|
||||
const karte = regel(datei, ".zt-karte");
|
||||
const bildSpalteLinks = 1512 - px(held.padding.split(" ")[1]) - px(rechts);
|
||||
expect(bildSpalteLinks + px(karte.left) + px(karte.width)).toBeLessThanOrEqual(1150);
|
||||
// Lesbarkeit der Status-Pille: Zeilenhöhe nicht winzig, Text hell auf dunkel oder dunkel auf hell (kein Weiß auf Weiß)
|
||||
expect(regel(datei, ".zt-pille").background).toBeDefined();
|
||||
});
|
||||
|
||||
it("die Leiste gleitet per transform ein und schiebt den Inhalt mit (nur in Phase „leiste“)", () => {
|
||||
const datei = "demos/zeiten/zeiten.css";
|
||||
expect(regel(datei, ".zt-leiste").transform).toMatch(/translateY\(-100%\)/);
|
||||
expect(regel(datei, ".zt-leiste").opacity).toBe("0");
|
||||
expect(regel(datei, '.zt-seite[data-phase="leiste"] .zt-leiste').transform).toBe("none");
|
||||
expect(regel(datei, '.zt-seite[data-phase="leiste"] .zt-inhalt').transform).toMatch(/translateY\(var\(--zt-leiste-h\)\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZeitenHandy", () => {
|
||||
it("der Finger ist nur in Phase „tippen“ zu sehen, ohne Zeiger-Ereignisse und aria-hidden", () => {
|
||||
const { container, rerender } = render(<ZeitenHandy zustand={bei(ENDE_UHR, { phase: "lauf" })} />);
|
||||
const wurzel = container.firstElementChild as HTMLElement;
|
||||
expect(wurzel).toHaveAttribute("aria-hidden", "true");
|
||||
expect(wurzel.style.pointerEvents).toBe("none");
|
||||
expect(container.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "nein");
|
||||
expect(container.querySelector(".dz-zeiger")).toHaveAttribute("data-art", "finger");
|
||||
rerender(<ZeitenHandy zustand={bei(ENDE_UHR, { phase: "tippen" })} />);
|
||||
expect(container.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "ja");
|
||||
rerender(<ZeitenHandy zustand={bei(ENDE_UHR, { phase: "leiste", leiste: true })} />);
|
||||
expect(container.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "nein");
|
||||
});
|
||||
|
||||
it("der Finger gleitet zum Knopf (Standard HINWEIS_ANLEGEN_PUNKT oder die Prop punkt) und tippt (Klickwelle ab klick > 0)", () => {
|
||||
const { container, rerender } = render(<ZeitenHandy zustand={bei(ENDE_UHR, { phase: "tippen", klick: 0 })} />);
|
||||
const zeiger = () => container.querySelector(".dz-zeiger") as HTMLElement;
|
||||
expect(zeiger().style.transform).toBe(`translate(${HINWEIS_ANLEGEN_PUNKT.x}px, ${HINWEIS_ANLEGEN_PUNKT.y}px)`);
|
||||
expect(container.querySelector(".dz-welle")).toBeNull();
|
||||
rerender(<ZeitenHandy zustand={bei(ENDE_UHR, { phase: "tippen", klick: 1 })} punkt={{ x: 120, y: 500 }} />);
|
||||
expect(zeiger().style.transform).toBe("translate(120px, 500px)");
|
||||
expect(container.querySelector(".dz-welle")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("wartet in der Ruhelage, bis der Finger zum Knopf gleitet (nicht schon auf dem Knopf)", () => {
|
||||
const { container } = render(<ZeitenHandy zustand={bei(700, { phase: "lauf" })} />);
|
||||
const ruhe = (container.querySelector(".dz-zeiger") as HTMLElement).style.transform;
|
||||
expect(ruhe).not.toBe(`translate(${HINWEIS_ANLEGEN_PUNKT.x}px, ${HINWEIS_ANLEGEN_PUNKT.y}px)`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZeitenRegler", () => {
|
||||
it("ist ein echter Regler mit Label, Bereich 05:00 bis 22:00 in Zehn-Minuten-Schritten und lesbarem Wert", () => {
|
||||
render(<ZeitenRegler zustand={bei(ENDE_UHR, { phase: "leiste", leiste: true })} />);
|
||||
const regler = screen.getByRole("slider", { name: "Uhrzeit testen" }) as HTMLInputElement;
|
||||
expect(regler.tagName).toBe("INPUT");
|
||||
expect(regler).toHaveAttribute("type", "range");
|
||||
expect(regler).toHaveAttribute("min", String(ZEIT_MIN));
|
||||
expect(regler).toHaveAttribute("max", String(ZEIT_MAX));
|
||||
expect(regler).toHaveAttribute("step", String(ZEIT_SCHRITT));
|
||||
expect(regler.value).toBe(String(ENDE_UHR));
|
||||
expect(regler).toHaveAttribute("aria-valuetext", "18:10 Uhr, Geschlossen");
|
||||
expect(document.querySelector("label[for]")).toHaveAttribute("for", regler.id);
|
||||
});
|
||||
|
||||
it("zeigt Uhrzeit und Statussatz zur Uhrzeit (nie „24“)", () => {
|
||||
const { container, rerender } = render(<ZeitenRegler zustand={bei(START_UHR)} />);
|
||||
expect(container.querySelector(".zr__uhr")).toHaveTextContent("05:50");
|
||||
expect(container.querySelector(".zr__status")).toHaveTextContent(ZU_HEUTE);
|
||||
rerender(<ZeitenRegler zustand={bei(700)} />);
|
||||
expect(container.querySelector(".zr__uhr")).toHaveTextContent("11:40");
|
||||
expect(container.querySelector(".zr__status")).toHaveTextContent(OFFEN);
|
||||
expect(screen.getByRole("slider")).toHaveAttribute("aria-valuetext", "11:40 Uhr, Geöffnet");
|
||||
rerender(<ZeitenRegler zustand={bei(ZEIT_MAX)} />);
|
||||
expect(container.textContent).not.toMatch(/24/);
|
||||
});
|
||||
|
||||
it("Bedienen ruft setzeUhr mit der Zahl auf (Ziehen, Pfeiltasten) und stoppt damit den Ablauf", () => {
|
||||
const setzeUhr = vi.fn();
|
||||
render(<ZeitenRegler zustand={{ ...START, setzeUhr }} />);
|
||||
const regler = screen.getByRole("slider");
|
||||
fireEvent.change(regler, { target: { value: "700" } });
|
||||
expect(setzeUhr).toHaveBeenCalledWith(700);
|
||||
expect(setzeUhr).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("der Statussatz meldet sich nur nach einer Bedienung (aria-live), nicht beim Automatiklauf", () => {
|
||||
const { container, rerender } = render(<ZeitenRegler zustand={bei(700, { phase: "lauf", laeuft: true })} />);
|
||||
expect(container.querySelector(".zr__status")).toHaveAttribute("aria-live", "off");
|
||||
rerender(<ZeitenRegler zustand={bei(700, { phase: "leiste", leiste: true, bedient: true })} />);
|
||||
expect(container.querySelector(".zr__status")).toHaveAttribute("aria-live", "polite");
|
||||
});
|
||||
|
||||
it("kompakt für die Handy-Karte; ohne Zustand im Anfangszustand", () => {
|
||||
const { container } = render(<ZeitenRegler kompakt />);
|
||||
expect(container.firstElementChild).toHaveClass("zr", "zr--kompakt");
|
||||
expect((screen.getByRole("slider") as HTMLInputElement).value).toBe(String(START_UHR));
|
||||
});
|
||||
|
||||
it("Tippfläche des Reglers ≥ 44 px, Wischen quer erlaubt die Wisch-Reihe nicht zu stören (touch-action)", () => {
|
||||
const werte = regel("demos/zeiten/zeiten.css", ".zr__regler");
|
||||
expect(px(werte.height)).toBeGreaterThanOrEqual(44);
|
||||
expect(werte["touch-action"]).toBe("pan-y");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZeitenSteuerung", () => {
|
||||
const rendern = (props: { laeuft: boolean; statisch: boolean; aktiv: boolean }, onZustand = vi.fn()) => {
|
||||
const r = render(<ZeitenSteuerung {...props} onZustand={onZustand} />);
|
||||
return { ...r, onZustand };
|
||||
};
|
||||
const letzter = (f: ReturnType<typeof vi.fn>) => f.mock.calls[f.mock.calls.length - 1][0] as ZeitenZustand;
|
||||
|
||||
it("rendert nichts und meldet den Zustand", () => {
|
||||
vi.useFakeTimers();
|
||||
const { container, onZustand } = rendern({ laeuft: true, statisch: false, aktiv: true });
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(letzter(onZustand)).toMatchObject({ phase: "start", uhr: START_UHR, laeuft: true });
|
||||
});
|
||||
|
||||
it("verlässt die Station (aktiv = false), geht eine Bedienung zurück an den Ablauf", () => {
|
||||
vi.useFakeTimers();
|
||||
const { rerender, onZustand } = rendern({ laeuft: true, statisch: false, aktiv: true });
|
||||
act(() => letzter(onZustand).setzeUhr(700));
|
||||
expect(letzter(onZustand)).toMatchObject({ bedient: true, uhr: 700 });
|
||||
rerender(<ZeitenSteuerung laeuft={false} statisch={false} aktiv={false} onZustand={onZustand} />);
|
||||
expect(letzter(onZustand)).toMatchObject({ bedient: false, phase: "start", uhr: START_UHR });
|
||||
});
|
||||
|
||||
it("statisch: auch ohne aktive Station geht setzeUhr(null) zurück auf den Endzustand 18:10", () => {
|
||||
vi.useFakeTimers();
|
||||
const { rerender, onZustand } = rendern({ laeuft: false, statisch: true, aktiv: true });
|
||||
act(() => letzter(onZustand).setzeUhr(400));
|
||||
expect(letzter(onZustand)).toMatchObject({ bedient: true, uhr: 400 });
|
||||
rerender(<ZeitenSteuerung laeuft={false} statisch aktiv={false} onZustand={onZustand} />);
|
||||
expect(letzter(onZustand)).toMatchObject({ bedient: false, uhr: ENDE_UHR, leiste: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("nichts fokussierbar außer dem Regler", () => {
|
||||
it("nichts fokussierbar außer genau einem Regler", () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<ZeitenSeite zustand={ENDE} variante="desktop" />
|
||||
<ZeitenSeite zustand={START} variante="handy" />
|
||||
<ZeitenHandy zustand={bei(ENDE_UHR, { phase: "tippen" })} />
|
||||
<ZeitenRegler zustand={START} />
|
||||
</>,
|
||||
);
|
||||
const treffer = Array.from(container.querySelectorAll(FOKUSSIERBAR));
|
||||
expect(treffer).toHaveLength(1);
|
||||
expect(treffer[0]).toBe(screen.getByRole("slider"));
|
||||
// die Seiten und die Handy-Ebene enthalten selbst nichts Fokussierbares
|
||||
for (const wurzel of container.querySelectorAll(".zt-seite, .dz-zeiger")) expect(wurzel.querySelectorAll(FOKUSSIERBAR)).toHaveLength(0);
|
||||
expect(container.querySelectorAll(".zt-seite input, .zt-seite [tabindex]")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bühne: Station Öffnungszeiten live", () => {
|
||||
type Eintrag = { rueckruf: IntersectionObserverCallback; optionen?: IntersectionObserverInit; ziele: Element[] };
|
||||
const beobachter: Eintrag[] = [];
|
||||
let zuruecksetzen: (() => void) | undefined;
|
||||
|
||||
const mitBreite = (desktop: boolean, bewegung = false) => {
|
||||
const vorher = window.matchMedia;
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches:
|
||||
(desktop && (query.includes("min-width: 900px") || query.includes("hover: hover"))) ||
|
||||
(bewegung && query.includes("prefers-reduced-motion: no-preference")),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as typeof window.matchMedia;
|
||||
zuruecksetzen = () => {
|
||||
window.matchMedia = vorher;
|
||||
};
|
||||
};
|
||||
const beobachterAttrappe = () => {
|
||||
beobachter.length = 0;
|
||||
vi.stubGlobal(
|
||||
"IntersectionObserver",
|
||||
class {
|
||||
eintrag: Eintrag;
|
||||
constructor(rueckruf: IntersectionObserverCallback, optionen?: IntersectionObserverInit) {
|
||||
this.eintrag = { rueckruf, optionen, ziele: [] };
|
||||
beobachter.push(this.eintrag);
|
||||
}
|
||||
observe(ziel: Element) {
|
||||
this.eintrag.ziele.push(ziel);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
const meldeMarke = (index: number) => {
|
||||
const spur = beobachter.find((b) => b.optionen?.rootMargin === "-50% 0px -50% 0px");
|
||||
expect(spur).toBeDefined();
|
||||
act(() => {
|
||||
spur!.rueckruf([{ isIntersecting: true, target: spur!.ziele[index] } as unknown as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
});
|
||||
};
|
||||
const desktop = () => {
|
||||
beobachterAttrappe();
|
||||
mitBreite(true);
|
||||
return render(<Kundenportal />);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
zuruecksetzen?.();
|
||||
zuruecksetzen = undefined;
|
||||
});
|
||||
|
||||
it("Bühne: neun Stationen, Station 8 ist Öffnungszeiten live mit „Bald verfügbar“", () => {
|
||||
const { container } = desktop();
|
||||
expect(beobachter.find((b) => b.optionen?.rootMargin === "-50% 0px -50% 0px")!.ziele).toHaveLength(9);
|
||||
const unterliste = container.querySelector<HTMLElement>("#kp-unterliste")!;
|
||||
const unter = within(unterliste).getAllByRole("button", { hidden: true });
|
||||
expect(unter).toHaveLength(4);
|
||||
expect(unter[3]).toHaveTextContent(/^Öffnungszeiten live/);
|
||||
expect(unter[3].querySelector(".kp-bald")).toHaveTextContent("Bald verfügbar");
|
||||
meldeMarke(7);
|
||||
expect(container.querySelector(".kp-buehne")).toHaveAttribute("data-station", "oeffnungszeiten");
|
||||
expect(unter[3]).toHaveAttribute("aria-current", "step");
|
||||
// „Als App“ ist jetzt die neunte Station und bleibt die letzte
|
||||
meldeMarke(8);
|
||||
expect(container.querySelector(".kp-buehne")).toHaveAttribute("data-app", "ja");
|
||||
});
|
||||
|
||||
it("Regler inaktiv: aria-hidden und inert; nur bei der Station Öffnungszeiten live bedienbar", async () => {
|
||||
const { container } = desktop();
|
||||
const regler = () => container.querySelector(".kp-regler") as HTMLElement;
|
||||
expect(regler()).toHaveAttribute("data-an", "nein");
|
||||
expect(regler()).toHaveAttribute("aria-hidden", "true");
|
||||
expect(regler()).toHaveAttribute("inert");
|
||||
expect(regler().querySelector("input")).toBeNull(); // noch nicht eingehängt
|
||||
|
||||
meldeMarke(7);
|
||||
await waitFor(() => expect(regler().querySelector('input[type="range"]')).not.toBeNull());
|
||||
expect(regler()).toHaveAttribute("data-an", "ja");
|
||||
expect(regler()).not.toHaveAttribute("aria-hidden");
|
||||
expect(regler()).not.toHaveAttribute("inert");
|
||||
expect(within(regler()).getByRole("slider", { name: "Uhrzeit testen" })).toBeInTheDocument();
|
||||
|
||||
meldeMarke(6);
|
||||
expect(regler()).toHaveAttribute("data-an", "nein");
|
||||
expect(regler()).toHaveAttribute("aria-hidden", "true");
|
||||
expect(regler()).toHaveAttribute("inert");
|
||||
meldeMarke(8);
|
||||
expect(regler()).toHaveAttribute("inert");
|
||||
});
|
||||
|
||||
it("MacBook: die Beispiel-Website der Bäckerei (nachgeladen, aria-hidden, Beschreibung); ohne Bewegung Endzustand mit Leiste", async () => {
|
||||
const { container } = desktop();
|
||||
meldeMarke(7);
|
||||
await waitFor(() => expect(container.querySelector(".kp-fenster .kp-lage.is-aktiv .zt-seite")).not.toBeNull());
|
||||
const ebene = container.querySelector(".kp-fenster .kp-lage.is-aktiv") as HTMLElement;
|
||||
expect(ebene).toHaveAttribute("role", "img");
|
||||
expect(ebene.getAttribute("aria-label")).toMatch(/Bäckerei/);
|
||||
expect(ebene.getAttribute("aria-label")).not.toMatch(/Google|Ranking|Umsatz/i);
|
||||
const seite = ebene.querySelector(".zt-seite") as HTMLElement;
|
||||
expect(seite).toHaveAttribute("aria-hidden", "true");
|
||||
expect(seite).toHaveAttribute("data-variante", "desktop");
|
||||
// diese Bühne meldet keine erlaubte Bewegung: fester Endzustand (18:10 mit Leiste)
|
||||
await waitFor(() => expect(seite).toHaveAttribute("data-phase", "leiste"));
|
||||
expect(seite.querySelector(".zt-pille")).toHaveTextContent(ZU_MORGEN);
|
||||
expect(container.querySelector(".dm-macbook__url")?.textContent).toBe("baeckerei-sonnenschein.example");
|
||||
expect(container.querySelector(".kp-beispiel[data-an='ja']")).toHaveTextContent("Erfundenes Beispiel · baeckerei-sonnenschein.example");
|
||||
});
|
||||
|
||||
it("iPhone: Portal-Aufnahme „Öffnungszeiten im Kundenportal bearbeiten“ mit Finger-Ebene darüber", async () => {
|
||||
const { container } = desktop();
|
||||
meldeMarke(7);
|
||||
const bild = () => container.querySelector(".kp-handy .kp-lage.is-aktiv img") as HTMLImageElement;
|
||||
expect(bild().getAttribute("src")).toBe("/relaunch/portal/phone-12-zeiten-780.webp");
|
||||
expect(bild().getAttribute("alt")).toBe("Öffnungszeiten im Kundenportal bearbeiten");
|
||||
await waitFor(() => expect(container.querySelector(".kp-handy .kp-lage.is-aktiv .dz-zeiger")).not.toBeNull());
|
||||
expect(container.querySelector(".kp-handy .kp-lage.is-aktiv")?.querySelector("[aria-hidden='true'] .dz-zeiger")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("die Bilddateien der Station gibt es und halten das Budget (Handy ≤ 60 KB)", () => {
|
||||
for (const breite of [390, 780]) {
|
||||
const datei = `${process.cwd()}/public/relaunch/portal/phone-12-zeiten-${breite}.webp`;
|
||||
expect(existsSync(datei), datei).toBe(true);
|
||||
expect(statSync(datei).size / 1024, datei).toBeLessThanOrEqual(60);
|
||||
}
|
||||
});
|
||||
|
||||
it("Handy: die Karte zeigt die Website im Rahmen und darunter den Regler, geladen erst in Sicht", async () => {
|
||||
beobachterAttrappe();
|
||||
mitBreite(false);
|
||||
const { container } = render(<Kundenportal />);
|
||||
const karte = container.querySelector(".kp-karte--demo[data-demo='zeiten']") as HTMLElement;
|
||||
expect(karte).not.toBeNull();
|
||||
expect(within(karte).getByRole("heading", { level: 3, name: "Öffnungszeiten live" })).toBeInTheDocument();
|
||||
expect(karte.querySelector(".kp-bald")).toHaveTextContent("Bald verfügbar");
|
||||
expect(karte.querySelector(".zt-seite")).toBeNull();
|
||||
expect(karte.querySelector("input")).toBeNull();
|
||||
act(() => {
|
||||
for (const b of beobachter) {
|
||||
const ziele = b.ziele.filter((z) => z.classList.contains("kp-karte__geraet"));
|
||||
if (ziele.length) b.rueckruf(ziele.map((target) => ({ isIntersecting: true, target }) as unknown as IntersectionObserverEntry), {} as IntersectionObserver);
|
||||
}
|
||||
});
|
||||
await waitFor(() => expect(karte.querySelector(".zt-seite[data-variante='handy']")).not.toBeNull());
|
||||
await waitFor(() => expect(karte.querySelector(".kp-karte__regler input[type='range']")).not.toBeNull());
|
||||
expect(karte.querySelector(".zr")).toHaveClass("zr--kompakt");
|
||||
expect(karte.querySelector(".kp-karte__beispiel")?.textContent).toBe("Erfundenes Beispiel · baeckerei-sonnenschein.example");
|
||||
// ohne Bewegung: Endzustand 18:10; Regler bedienen zeigt den Status der gewählten Uhrzeit
|
||||
expect(karte.querySelector(".zt-seite")).toHaveAttribute("data-phase", "leiste");
|
||||
fireEvent.change(karte.querySelector("input[type='range']")!, { target: { value: "700" } });
|
||||
await waitFor(() => expect(karte.querySelector(".zt-pille")).toHaveTextContent(OFFEN));
|
||||
expect(karte.querySelector(".zr__uhr")).toHaveTextContent("11:40");
|
||||
// Seiten und Handy-Karte: außer dem Regler nichts fokussierbar
|
||||
expect(karte.querySelectorAll(FOKUSSIERBAR)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("Handy-Reihe und Bühne bleiben beschriftet: Neun Funktionen des Kundenportals", () => {
|
||||
beobachterAttrappe();
|
||||
mitBreite(false);
|
||||
render(<Kundenportal />);
|
||||
expect(screen.getByRole("list", { name: /^Neun Funktionen des Kundenportals, seitlich wischen$/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("die Bühne zieht aus ablauf.ts nur Typen (der Haupt-Chunk bleibt klein), die Komponenten sind lazy", () => {
|
||||
const quelle = readFileSync(`${process.cwd()}/src/components/home/Kundenportal.tsx`, "utf8");
|
||||
expect(quelle).toMatch(/import type \{ ZeitenZustand \} from "\.\/demos\/zeiten\/ablauf";/);
|
||||
expect(quelle).not.toMatch(/^import (?!type)[^;]*from "\.\/demos\/zeiten\//m);
|
||||
for (const name of ["ZeitenSeite", "ZeitenHandy", "ZeitenRegler"]) {
|
||||
expect(quelle).toMatch(new RegExp(`const ${name} = lazyMitVorladen\\(\\(\\) => import\\("\\./demos/zeiten/${name}"\\)\\)`));
|
||||
}
|
||||
expect(quelle).toMatch(/const HINWEIS_PUNKT = \{ x: \d+, y: \d+ \}/);
|
||||
});
|
||||
|
||||
it("der Tippunkt der Bühne und der Standardpunkt der Handy-Ebene sind derselbe Wert (Mitte von „Hinweis-Leiste anlegen“ in phone-12-zeiten) und liegen im iPhone", () => {
|
||||
const quelle = readFileSync(`${process.cwd()}/src/components/home/Kundenportal.tsx`, "utf8");
|
||||
const m = /const HINWEIS_PUNKT = \{ x: (\d+), y: (\d+) \}/.exec(quelle);
|
||||
expect(m).not.toBeNull();
|
||||
const punkt = { x: Number(m?.[1]), y: Number(m?.[2]) };
|
||||
expect(punkt).toEqual({ x: HINWEIS_ANLEGEN_PUNKT.x, y: HINWEIS_ANLEGEN_PUNKT.y });
|
||||
// Fläche 402 x 874 mit Safe Area oben 59 / unten 34: der Knopf liegt im sichtbaren Teil
|
||||
expect(punkt.x).toBeGreaterThan(0);
|
||||
expect(punkt.x).toBeLessThan(402);
|
||||
expect(punkt.y).toBeGreaterThan(59);
|
||||
expect(punkt.y).toBeLessThan(874 - 34);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Quelltext der Station", () => {
|
||||
const quelle = (pfad: string) => readFileSync(`${process.cwd()}/src/components/home/demos/${pfad}`, "utf8");
|
||||
const DATEIEN = ["zeiten/ablauf.ts", "zeiten/ZeitenSeite.tsx", "zeiten/ZeitenHandy.tsx", "zeiten/ZeitenRegler.tsx", "zeiten/zeiten.css"];
|
||||
|
||||
it("kein https://, keine fremden Marken oder Versprechen, kein „24 h“, kein WhatsApp-Logo", () => {
|
||||
for (const datei of DATEIEN) {
|
||||
const text = quelle(datei).replace(/http:\/\/www\.w3\.org\/2000\/svg/g, "");
|
||||
expect(text, datei).not.toMatch(/https?:\/\//);
|
||||
expect(text, datei).not.toMatch(/googleapis|gstatic|unsplash|google|ranking|mehr Kunden|mehr Umsatz|garantiert/i);
|
||||
expect(text, datei).not.toMatch(/24[\s-]*(Stunden|Std|h)\b/i);
|
||||
expect(text, datei).not.toMatch(/whatsapp/i);
|
||||
expect(text, datei).not.toContain("Webklar");
|
||||
expect(text, datei).not.toContain("99,9");
|
||||
}
|
||||
});
|
||||
|
||||
it("Bewegung nur transform/opacity hinter no-preference", () => {
|
||||
const css = quelle("zeiten/zeiten.css").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const mitBewegung = css.split("@media (prefers-reduced-motion: no-preference)");
|
||||
expect(mitBewegung.length, "es gibt einen Block hinter no-preference").toBeGreaterThan(1);
|
||||
expect(mitBewegung[0]).not.toMatch(/animation\s*:|transition\s*:|animation-|transition-/);
|
||||
for (const treffer of css.matchAll(/@keyframes[^{]+\{([\s\S]*?)\n\}/g)) {
|
||||
for (const e of [...treffer[1].matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1])) {
|
||||
expect(["transform", "opacity", "animation-timing-function"]).toContain(e);
|
||||
}
|
||||
}
|
||||
for (const m of css.matchAll(/transition\s*:\s*([^;]+);/g)) {
|
||||
for (const teil of m[1].split(/,(?![^(]*\))/)) expect(["transform", "opacity"], m[0]).toContain(teil.trim().split(/\s+/)[0]);
|
||||
}
|
||||
// keine Layout-Eigenschaften in Übergängen oder Animationen (will-change nur transform/opacity)
|
||||
expect(css).not.toMatch(/will-change\s*:\s*(?!transform|opacity)/);
|
||||
});
|
||||
|
||||
it("Seite und Handy-Ebene sind reine Kulisse: keine Ereignisse, keine Hover-Regeln", () => {
|
||||
expect(quelle("zeiten/ZeitenSeite.tsx")).not.toMatch(/\bon[A-Z][A-Za-z]+=/);
|
||||
expect(quelle("zeiten/ZeitenHandy.tsx")).not.toMatch(/\bon[A-Z][A-Za-z]+=/);
|
||||
expect(quelle("zeiten/zeiten.css")).not.toMatch(/:hover/);
|
||||
});
|
||||
|
||||
it("Seite und Regler holen ihr CSS selbst (der Taktgeber ablauf.ts nicht), baeckerei.css kommt vor zeiten.css", () => {
|
||||
const seite = quelle("zeiten/ZeitenSeite.tsx");
|
||||
expect(seite).toMatch(/import "\.\.\/baeckerei\/baeckerei\.css";\s*import "\.\/zeiten\.css";/);
|
||||
expect(quelle("zeiten/ZeitenRegler.tsx")).toMatch(/import "\.\/zeiten\.css";/);
|
||||
expect(quelle("zeiten/ablauf.ts")).not.toMatch(/\.css/);
|
||||
expect(seite).toMatch(/from "\.\.\/baeckerei\/teile"/);
|
||||
});
|
||||
|
||||
it("kundenportal.css kennt .kp-regler in den Selektorlisten und .kp-karte__regler", () => {
|
||||
const css = readFileSync(`${process.cwd()}/src/components/home/kundenportal.css`, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
expect(css).toMatch(/\.kp-regler,/);
|
||||
expect(css).toMatch(/\.kp-regler\[data-an="ja"\]/);
|
||||
expect(css).toMatch(/\.kp-karte__regler\s*\{/);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -5,14 +5,14 @@ import { join } from "node:path";
|
||||
import Kundenportal from "@/components/home/Kundenportal";
|
||||
import GeraeteRahmen, { DesktopFenster, HandyRahmen } from "@/components/GeraeteRahmen";
|
||||
|
||||
// Spezifikation 7.2 (Kundenportal) und 9.5 (Bilder); Funktions-Demos: 2026-10-04-funktionen-demos (Stationen 5 bis 7);
|
||||
// Spezifikation 7.2 (Kundenportal) und 9.5 (Bilder); Funktions-Demos: 2026-10-04-funktionen-demos (Stationen 5 bis 8: Terminplaner, Website-Analyse, News-Pinnwand, Öffnungszeiten live);
|
||||
// Live-Vorschau, Chat-Demo und weiches Auf-/Zuklappen: 2026-10-04-vorschau-chat
|
||||
|
||||
const WURZEL = `${process.cwd()}/`;
|
||||
const BILDORDNER = join(WURZEL, "public/relaunch/portal");
|
||||
|
||||
const TITEL = ["Live-Vorschau", "Fortschritt", "Chat", "Rechnungen", "Terminplaner", "Website-Analyse", "News-Pinnwand", "Als App"];
|
||||
/** Ein Satz je Portal-Station; die drei Funktionen zeigen nur ihren Titel (Wunsch des Inhabers 2026-10-04). */
|
||||
const TITEL = ["Live-Vorschau", "Fortschritt", "Chat", "Rechnungen", "Terminplaner", "Website-Analyse", "News-Pinnwand", "Öffnungszeiten live", "Als App"];
|
||||
/** Ein Satz je Portal-Station; die vier Funktionen zeigen nur ihren Titel (Wunsch des Inhabers 2026-10-04). */
|
||||
const UNTERSCHRIFTEN: (string | null)[] = [
|
||||
"Ihre Website, noch während wir daran bauen.",
|
||||
"Was wir zuletzt erledigt haben – in einfachen Sätzen.",
|
||||
@@ -21,6 +21,7 @@ const UNTERSCHRIFTEN: (string | null)[] = [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"Aufs Handy installieren, fertig.",
|
||||
];
|
||||
const SCHRITTE = [
|
||||
@@ -98,12 +99,12 @@ describe("Kundenportal: Text und Reihenfolge", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Handy: die acht Funktionen stehen in der richtigen Reihenfolge mit ihren Unterschriften", () => {
|
||||
it("Handy: die neun Funktionen stehen in der richtigen Reihenfolge mit ihren Unterschriften", () => {
|
||||
zeigeHandy();
|
||||
expect(screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent)).toEqual(TITEL);
|
||||
for (const text of UNTERSCHRIFTEN) if (text) expect(screen.getByText(text)).toBeInTheDocument();
|
||||
// die drei Funktions-Karten: nur der Titel, kein Satz und keine Stichpunkte
|
||||
for (const titel of ["Terminplaner", "Website-Analyse", "News-Pinnwand"]) {
|
||||
// die vier Funktions-Karten: nur der Titel, kein Satz und keine Stichpunkte
|
||||
for (const titel of ["Terminplaner", "Website-Analyse", "News-Pinnwand", "Öffnungszeiten live"]) {
|
||||
const karte = screen.getByRole("heading", { level: 3, name: titel }).closest(".kp-karte")!;
|
||||
expect(karte.querySelector(".kp-karte__text, ul")).toBeNull();
|
||||
}
|
||||
@@ -121,30 +122,37 @@ describe("Kundenportal: Text und Reihenfolge", () => {
|
||||
oben.forEach((titel, i) => expect(eintraege[i]).toHaveTextContent(new RegExp(`^${titel}`)));
|
||||
// Portal-Stationen behalten ihren Satz
|
||||
[0, 1, 2, 3].forEach((i) => expect(eintraege[i]).toHaveTextContent(UNTERSCHRIFTEN[i] as string));
|
||||
expect(eintraege[5]).toHaveTextContent(UNTERSCHRIFTEN[7] as string);
|
||||
// Überpunkt: zugeklappt, die drei Unterpunkte (nur Titel, keine Stichpunkte) sind versteckt
|
||||
expect(eintraege[5]).toHaveTextContent(UNTERSCHRIFTEN[8] as string);
|
||||
// Überpunkt: zugeklappt, die vier Unterpunkte (nur Titel, keine Stichpunkte) sind versteckt
|
||||
const gruppe = eintraege[4];
|
||||
expect(gruppe).toHaveAttribute("aria-expanded", "false");
|
||||
const unterliste = liste.querySelector<HTMLElement>("#kp-unterliste")!;
|
||||
expect(unterliste).toHaveAttribute("aria-label", "Funktionen");
|
||||
expect(unterliste).not.toBeVisible();
|
||||
const unter = within(unterliste).getAllByRole("button", { hidden: true });
|
||||
expect(unter.map((b) => b.textContent)).toEqual(["TerminplanerBald verfügbar", "Website-Analyse", "News-Pinnwand"]);
|
||||
expect(unter.map((b) => b.textContent)).toEqual([
|
||||
"TerminplanerBald verfügbar",
|
||||
"Website-Analyse",
|
||||
"News-Pinnwand",
|
||||
"Öffnungszeiten liveBald verfügbar",
|
||||
]);
|
||||
expect(liste).not.toHaveTextContent("Heatmap: wohin Besucher klicken");
|
||||
});
|
||||
|
||||
it("der Terminplaner trägt „Bald verfügbar“ (noch nicht buchbar), die anderen Stationen nicht", () => {
|
||||
it("Terminplaner und Öffnungszeiten live tragen „Bald verfügbar“ (noch nicht buchbar), die anderen Stationen nicht", () => {
|
||||
zeigeDesktop();
|
||||
const liste = screen.getByRole("list", { name: "Funktionen des Kundenportals" });
|
||||
const marken = liste.querySelectorAll(".kp-bald");
|
||||
expect(marken).toHaveLength(1);
|
||||
expect(marken).toHaveLength(2);
|
||||
expect(marken[0].closest("button")).toHaveTextContent(/^Terminplaner/);
|
||||
expect(marken[1].closest("button")).toHaveTextContent(/^Öffnungszeiten live/);
|
||||
cleanup();
|
||||
|
||||
zeigeHandy();
|
||||
const karten = screen.getAllByText("Bald verfügbar");
|
||||
expect(karten).toHaveLength(1);
|
||||
expect(karten).toHaveLength(2);
|
||||
expect(karten[0].closest(".kp-karte")).toContainElement(screen.getByRole("heading", { level: 3, name: "Terminplaner" }));
|
||||
expect(karten[1].closest(".kp-karte")).toContainElement(screen.getByRole("heading", { level: 3, name: "Öffnungszeiten live" }));
|
||||
});
|
||||
|
||||
it("Tablet quer (Bühne ohne Maus): der Bedienhinweis der Website-Analyse sagt „Tippen Sie“ statt „Zeigen Sie mit der Maus“", () => {
|
||||
@@ -157,7 +165,7 @@ describe("Kundenportal: Text und Reihenfolge", () => {
|
||||
it.each([
|
||||
["Handy", zeigeHandy],
|
||||
["Desktop", zeigeDesktop],
|
||||
])("%s: bewirbt weder Stundenkonto noch Verträge (Terminplaner, Analyse und Pinnwand sind die Demos)", (_name, zeige) => {
|
||||
])("%s: bewirbt weder Stundenkonto noch Verträge (Terminplaner, Analyse, Pinnwand und Öffnungszeiten live sind die Demos)", (_name, zeige) => {
|
||||
const { container } = zeige();
|
||||
const alts = Array.from(container.querySelectorAll("img")).map((img) => img.getAttribute("alt") ?? "");
|
||||
const gesamt = `${container.textContent} ${alts.join(" ")}`;
|
||||
@@ -186,7 +194,7 @@ describe("Kundenportal: Bilder (Spezifikation 9.5)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("Handy: fünf Bilder, nur Handy-Aufnahmen (die drei Demo-Karten zeigen Websites); das lange Bild behält sein hohes Seitenverhältnis", () => {
|
||||
it("Handy: fünf Bilder, nur Handy-Aufnahmen (die vier Demo-Karten zeigen Websites); das lange Bild behält sein hohes Seitenverhältnis", () => {
|
||||
const { container } = zeigeHandy();
|
||||
const bilder = Array.from(container.querySelectorAll("img"));
|
||||
expect(bilder).toHaveLength(5);
|
||||
@@ -276,17 +284,17 @@ describe("Kundenportal: Schrittzeile", () => {
|
||||
});
|
||||
|
||||
describe("Kundenportal: Wisch-Reihen und Bedienung", () => {
|
||||
it("Handy: beide Wisch-Reihen sind per Tastatur erreichbar und beschriftet, höchstens 8 Karten", () => {
|
||||
it("Handy: beide Wisch-Reihen sind per Tastatur erreichbar und beschriftet, höchstens 9 Karten", () => {
|
||||
zeigeHandy();
|
||||
const reihen = screen.getAllByRole("list").filter((l) => l.classList.contains("wk-wisch__reihe"));
|
||||
expect(reihen).toHaveLength(2);
|
||||
for (const reihe of reihen) {
|
||||
expect(reihe).toHaveAttribute("tabindex", "0");
|
||||
expect(reihe.getAttribute("aria-label")).toMatch(/wischen/);
|
||||
expect(reihe.children.length).toBeLessThanOrEqual(8);
|
||||
expect(reihe.children.length).toBeLessThanOrEqual(9);
|
||||
}
|
||||
expect(reihen[0].children).toHaveLength(8);
|
||||
expect(reihen[0].getAttribute("aria-label")).toMatch(/^Acht Funktionen/);
|
||||
expect(reihen[0].children).toHaveLength(9);
|
||||
expect(reihen[0].getAttribute("aria-label")).toMatch(/^Neun Funktionen/);
|
||||
});
|
||||
|
||||
it("Desktop: keine Wisch-Reihe, dafür die Bühne", () => {
|
||||
@@ -329,7 +337,7 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
const meldeMarke = (index: number) => {
|
||||
const spur = beobachter.find((b) => b.optionen?.rootMargin === "-50% 0px -50% 0px");
|
||||
expect(spur, "Beobachter auf der Spur aus Markern").toBeDefined();
|
||||
expect(spur!.ziele).toHaveLength(8);
|
||||
expect(spur!.ziele).toHaveLength(9);
|
||||
act(() => {
|
||||
spur!.rueckruf([{ isIntersecting: true, target: spur!.ziele[index] } as unknown as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
});
|
||||
@@ -341,7 +349,7 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
.find((b) => b.getAttribute("aria-current") === "step")
|
||||
?.textContent;
|
||||
|
||||
it("der Überpunkt „Funktionen“ klappt auf, sobald eine der drei Funktionen erreicht ist, und danach wieder zu", () => {
|
||||
it("der Überpunkt „Funktionen“ klappt auf, sobald eine der vier Funktionen erreicht ist, und danach wieder zu", () => {
|
||||
beobachterAttrappe();
|
||||
zeigeDesktop();
|
||||
const gruppe = screen.getByRole("button", { name: /^Funktionen/ });
|
||||
@@ -353,11 +361,14 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
meldeMarke(5);
|
||||
expect(aktiverEintrag()).toBe("Website-Analyse");
|
||||
meldeMarke(7);
|
||||
expect(gruppe).toHaveAttribute("aria-expanded", "true");
|
||||
expect(aktiverEintrag()).toBe("Öffnungszeiten liveBald verfügbar");
|
||||
meldeMarke(8);
|
||||
expect(gruppe).toHaveAttribute("aria-expanded", "false");
|
||||
expect(aktiverEintrag()).toMatch(/^Als App/);
|
||||
});
|
||||
|
||||
it("die aktive Funktion ist hervorgehoben, die Bilder folgen, bei Schritt 8 blendet das Fenster aus", () => {
|
||||
it("die aktive Funktion ist hervorgehoben, die Bilder folgen, bei Schritt 9 blendet das Fenster aus", () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
expect(aktiverEintrag()).toMatch(/^Live-Vorschau/);
|
||||
@@ -373,39 +384,40 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
expect(lagen.filter((l) => !l.classList.contains("is-aktiv")).every((l) => l.getAttribute("aria-hidden") === "true")).toBe(true);
|
||||
expect(container.querySelector(".kp-buehne")).toHaveAttribute("data-app", "nein");
|
||||
|
||||
meldeMarke(7);
|
||||
meldeMarke(8);
|
||||
expect(aktiverEintrag()).toMatch(/^Als App/);
|
||||
expect(container.querySelector(".kp-buehne")).toHaveAttribute("data-app", "ja");
|
||||
expect(container.querySelector(".kp-fenster .kp-ebenen")).toHaveAttribute("aria-hidden", "true");
|
||||
// der lange Screenshot ist jetzt eingehängt und aktiv
|
||||
const lang = container.querySelector(".kp-handy .kp-lage.is-aktiv .kp-lang img");
|
||||
expect(lang?.getAttribute("src")).toContain("phone-2-projekte-lang");
|
||||
// alle früheren Bilder sind inzwischen eingehängt: zwei Desktop-Aufnahmen (Fortschritt, Rechnungen) und acht Handy-Aufnahmen
|
||||
// alle früheren Bilder sind inzwischen eingehängt: zwei Desktop-Aufnahmen (Fortschritt, Rechnungen) und neun Handy-Aufnahmen
|
||||
// (die Live-Stationen zeigen im MacBook Website bzw. Chat, deren Bilder liegen tiefer in der Ebene)
|
||||
expect(container.querySelectorAll(".kp-lage > img, .kp-lage > .kp-lang > img").length).toBe(2 + 8);
|
||||
expect(container.querySelectorAll(".kp-lage > img, .kp-lage > .kp-lang > img").length).toBe(2 + 9);
|
||||
});
|
||||
|
||||
it("Demo-Stationen: die drei Websites werden nachgeladen, sobald die Bühne sie fast erreicht, und aktiv ist nur eine", async () => {
|
||||
it("Demo-Stationen: die vier Websites werden nachgeladen, sobald die Bühne sie fast erreicht, und aktiv ist nur eine", async () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
// zu Beginn ist noch keine Demo eingehängt
|
||||
expect(container.querySelector(".kp-fenster .ts, .kp-fenster .hm, .kp-fenster .pw-seite")).toBeNull();
|
||||
expect(container.querySelector(".kp-fenster .ts, .kp-fenster .hm, .kp-fenster .pw-seite, .kp-fenster .zt-seite")).toBeNull();
|
||||
|
||||
meldeMarke(3); // Rechnungen: der nächste Schritt (Terminplaner) wird schon eingehängt
|
||||
await waitFor(() => expect(container.querySelector(".kp-fenster .ts")).not.toBeNull());
|
||||
expect(container.querySelector(".kp-fenster .hm")).toBeNull();
|
||||
|
||||
meldeMarke(6);
|
||||
meldeMarke(6); // Pinnwand: der nächste Schritt (Öffnungszeiten live) wird schon eingehängt
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".kp-fenster .hm")).not.toBeNull();
|
||||
expect(container.querySelector(".kp-fenster .pw-seite")).not.toBeNull();
|
||||
expect(container.querySelector(".kp-fenster .zt-seite")).not.toBeNull();
|
||||
});
|
||||
// jede Website liegt in einer eigenen Ebene: nur die aktive ist sichtbar, die anderen für Screenreader und Maus aus
|
||||
const lagen = Array.from(container.querySelectorAll(".kp-fenster .kp-lage"));
|
||||
expect(lagen.filter((l) => l.classList.contains("is-aktiv"))).toHaveLength(1);
|
||||
expect(container.querySelector(".kp-fenster .kp-lage.is-aktiv .pw-seite")).not.toBeNull();
|
||||
// Wurzel jeder Website: aria-hidden, nichts Fokussierbares, data-phase für die Prüfung
|
||||
for (const wurzel of Array.from(container.querySelectorAll(".kp-fenster .ts, .kp-fenster .hm, .kp-fenster .pw-seite"))) {
|
||||
for (const wurzel of Array.from(container.querySelectorAll(".kp-fenster .ts, .kp-fenster .hm, .kp-fenster .pw-seite, .kp-fenster .zt-seite"))) {
|
||||
expect(wurzel).toHaveAttribute("aria-hidden", "true");
|
||||
expect(wurzel).toHaveAttribute("data-phase");
|
||||
expect(wurzel.querySelectorAll("a, button, input, select, textarea, [tabindex]")).toHaveLength(0);
|
||||
@@ -429,6 +441,8 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
expect(adresse()).toBe("boulderhalle-ueberhang.example");
|
||||
meldeMarke(6);
|
||||
expect(adresse()).toBe("musterbach.example");
|
||||
meldeMarke(7);
|
||||
expect(adresse()).toBe("baeckerei-sonnenschein.example");
|
||||
meldeMarke(2);
|
||||
expect(adresse()).toBe("project.webklar.com");
|
||||
await waitFor(() => expect(container.querySelector(".kp-fenster .ts")).not.toBeNull());
|
||||
@@ -458,15 +472,16 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
// mit Maus (diese Bühne meldet eine feine Zeigereingabe): Hinweis zum Zeigen
|
||||
"Erfundenes Beispiel · Zeigen Sie mit der Maus auf die Seite.",
|
||||
"Erfundenes Beispiel · musterbach.example",
|
||||
"Erfundenes Beispiel · baeckerei-sonnenschein.example",
|
||||
]);
|
||||
expect(hinweise.map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "ja", "nein"]);
|
||||
expect(hinweise.map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "ja", "nein", "nein"]);
|
||||
|
||||
meldeMarke(6);
|
||||
expect(diagnose()).toHaveAttribute("data-an", "nein");
|
||||
expect(diagnose()).toHaveAttribute("inert");
|
||||
});
|
||||
|
||||
it("Live-Vorschau, Chat, Terminplaner und Pinnwand tragen unter dem MacBook eine Hinweiskarte; nur die der aktiven Station ist sichtbar", () => {
|
||||
it("Live-Vorschau, Chat, Terminplaner und Pinnwand tragen unter dem MacBook eine Hinweiskarte (Öffnungszeiten live den Regler); nur die der aktiven Station ist sichtbar", () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
const hinweise = () => Array.from(container.querySelectorAll(".kp-hinweis")) as HTMLElement[];
|
||||
@@ -490,6 +505,9 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "nein"]);
|
||||
meldeMarke(6);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "ja"]);
|
||||
// Öffnungszeiten live hat statt einer Hinweiskarte den Uhr-Regler
|
||||
meldeMarke(7);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "nein"]);
|
||||
});
|
||||
|
||||
it("Live-Vorschau: im MacBook läuft die Beispiel-Website der Bäckerei (nachgeladen, aria-hidden, nichts Fokussierbares)", async () => {
|
||||
@@ -529,9 +547,9 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
const gruppe = screen.getByRole("button", { name: /^Funktionen/ });
|
||||
const liste = screen.getByRole("list", { name: "Funktionen des Kundenportals" });
|
||||
const unterliste = liste.querySelector<HTMLElement>("#kp-unterliste")!;
|
||||
meldeMarke(6);
|
||||
expect(gruppe).toHaveAttribute("aria-expanded", "true");
|
||||
meldeMarke(7);
|
||||
expect(gruppe).toHaveAttribute("aria-expanded", "true");
|
||||
meldeMarke(8);
|
||||
// sofort zu für Screenreader, sichtbar faltet sich die Unterliste noch ein
|
||||
expect(gruppe).toHaveAttribute("aria-expanded", "false");
|
||||
expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "schliesst");
|
||||
@@ -541,7 +559,7 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "zu");
|
||||
// geht sie während des Einfaltens wieder auf, bleibt sie offen
|
||||
meldeMarke(5);
|
||||
meldeMarke(7);
|
||||
meldeMarke(8);
|
||||
meldeMarke(5);
|
||||
expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "offen");
|
||||
await new Promise((r) => setTimeout(r, 320));
|
||||
@@ -574,15 +592,15 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
expect(lage.querySelector(".kp-platzhalter, .ts")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Handy-Karten: die drei Websites laden erst, wenn die Karte in Sicht kommt; Tippen auf die Heatmap wählt eine Zone", async () => {
|
||||
it("Handy-Karten: die vier Websites laden erst, wenn die Karte in Sicht kommt; Tippen auf die Heatmap wählt eine Zone", async () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeHandy();
|
||||
// drei Funktions-Karten (Websites) und die Chat-Karte
|
||||
expect(container.querySelectorAll(".kp-karte--demo")).toHaveLength(4);
|
||||
// vier Funktions-Karten (Websites) und die Chat-Karte
|
||||
expect(container.querySelectorAll(".kp-karte--demo")).toHaveLength(5);
|
||||
const karten = Array.from(container.querySelectorAll(".kp-karte--demo[data-demo]"));
|
||||
expect(karten).toHaveLength(3);
|
||||
expect(karten).toHaveLength(4);
|
||||
// noch nichts gesehen: Rahmen leer, nichts geladen
|
||||
expect(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite)")).toHaveLength(0);
|
||||
expect(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite, .zt-seite)")).toHaveLength(0);
|
||||
// jede Karte meldet sich über ihre Beobachter als sichtbar
|
||||
act(() => {
|
||||
for (const b of beobachter) {
|
||||
@@ -592,8 +610,8 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
b.rueckruf(ziele.map((target) => ({ isIntersecting: true, target }) as unknown as IntersectionObserverEntry), {} as IntersectionObserver);
|
||||
}
|
||||
});
|
||||
await waitFor(() => expect(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite)[data-variante='handy']")).toHaveLength(3));
|
||||
for (const wurzel of Array.from(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite)[data-variante='handy']")))
|
||||
await waitFor(() => expect(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite, .zt-seite)[data-variante='handy']")).toHaveLength(4));
|
||||
for (const wurzel of Array.from(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite, .zt-seite)[data-variante='handy']")))
|
||||
expect(wurzel).toHaveAttribute("aria-hidden", "true");
|
||||
// Heatmap-Karte: kompakte Diagnose darunter, Tippen auf eine Zone wählt sie
|
||||
const heat = container.querySelector(".kp-karte--demo[data-demo='heatmap']") as HTMLElement;
|
||||
|
||||
@@ -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