Relaunch: Station Öffnungszeiten live

Vierter Eintrag der Funktionen-Gruppe (nach der News-Pinnwand, „Bald verfügbar“, ohne Preis):
- Website: Bäckerei Sonnenschein mit großer Status-Pille und Hinweis-Leiste „Betriebsurlaub 12.–26. Oktober“
  (gleitet per transform ein, der Inhalt rückt nach), Desktop und Handy.
- Handy-Ebene: Finger tippt „Hinweis-Leiste anlegen“ (HINWEIS_PUNKT, Platzhalter bis zur Aufnahme).
- Uhr-Regler: echter Schieberegler unter dem MacBook (inaktiv: aria-hidden + inert) und in der Handy-Karte.
- ZeitenSteuerung (setzeUhr(null) beim Verlassen), lazy-Chunks, Kundenportal importiert aus ablauf.ts nur Typen.
- Pins in relaunch-kundenportal.test.tsx auf neun Stationen angepasst (Als App = Marke 8), Spezifikations-Zähler mitgezogen.

ACHTUNG: public/relaunch/portal/phone-12-zeiten-{390,780}.webp sind vorläufig Kopien von phone-9-termine
(damit Build und Tests laufen); Task 36 ersetzt sie durch die echte Aufnahme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-05 08:05:49 +00:00
1 parent f7147fcd30
commit a23f2e52c2
14 files changed
+1234 -56

No files matched your search

@@ -249,7 +249,7 @@ Texte in **[eckigen Klammern]** brauchen die Freigabe des Inhabers (Abschnitt 11
- Keine festgepinnte Bühne. - Keine festgepinnte Bühne.
- Stattdessen eine **Wisch-Reihe** (9.4) mit je einem Handy-Screenshot im Rahmen und der Bildunterschrift darunter. - 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. - 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“): **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“ - 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. - Die Fokusreihenfolge entspricht der Anzeige.
- Kein `aria-live`, keine automatische Bewegung. - 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. - **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 ### 9.5 Bilder
- **Format und Auslieferung:** - **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. 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. - 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, - 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. `sofort-paket.test.tsx` (Scans gegen verbotene Aussagen) bleibt grün.
## 5. Offen für den Inhaber ## 5. Offen für den Inhaber
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+69 -5
View File
@@ -18,6 +18,7 @@ import type { TerminZustand } from "./demos/termin/ablauf";
import type { HeatmapZustand } from "./demos/heatmap/ablauf"; import type { HeatmapZustand } from "./demos/heatmap/ablauf";
import type { PinnwandZustand } from "./demos/pinnwand/ablauf"; import type { PinnwandZustand } from "./demos/pinnwand/ablauf";
import type { ChatZustand } from "./demos/chat/ablauf"; import type { ChatZustand } from "./demos/chat/ablauf";
import type { ZeitenZustand } from "./demos/zeiten/ablauf";
import "./kundenportal.css"; 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. * 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. * Darunter (unter 900 px): keine Bühne, sondern eine Wisch-Reihe mit je einem Handy-Screenshot.
* Danach die Schrittzeile (ersetzt "So arbeiten wir"). * 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 * 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. * 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): * 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, alt,
}); });
type DemoArt = "termin" | "heatmap" | "pinnwand"; type DemoArt = "termin" | "heatmap" | "pinnwand" | "zeiten";
/** Live-Stationen außerhalb der Gruppe „Funktionen“ */ /** Live-Stationen außerhalb der Gruppe „Funktionen“ */
type LiveArt = "vorschau" | "chat"; type LiveArt = "vorschau" | "chat";
type LadeArt = DemoArt | LiveArt; type LadeArt = DemoArt | LiveArt;
@@ -94,9 +95,9 @@ type Funktion = {
adresse?: string; adresse?: string;
/** Beschreibung der Beispiel-Website für Screenreader (die Website selbst ist aria-hidden). */ /** Beschreibung der Beispiel-Website für Screenreader (die Website selbst ist aria-hidden). */
beschreibung?: string; 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; 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; 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). */ /** 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 }; const AUFHAENGEN_PUNKT = { x: 201, y: 613 };
/** Mitte des Knopfes "Hinweis-Leiste anlegen" im Portal-Screenshot der Station Öffnungszeiten live (Platzhalter bis zur Aufnahme phone-12-zeiten). */
const HINWEIS_PUNKT = { x: 201, y: 740 };
// Reihenfolge und Texte laut Spezifikation 7.2. Platzhalter des Portals ("kommt bald") werden nicht beworben, // Reihenfolge und Texte laut Spezifikation 7.2. Platzhalter des Portals ("kommt bald") werden nicht beworben,
// weitere Ansichten nur nach Freigabe des Inhabers (O9 = nein). // 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.", 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", id: "als-app",
titel: "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 TerminSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.TerminSteuerung })));
const HeatmapSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.HeatmapSteuerung }))); const HeatmapSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.HeatmapSteuerung })));
const PinnwandSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.PinnwandSteuerung }))); 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 TerminSeite = lazyMitVorladen(() => import("./demos/termin/TerminSeite"));
const TerminHandy = lazyMitVorladen(() => import("./demos/termin/TerminHandy")); const TerminHandy = lazyMitVorladen(() => import("./demos/termin/TerminHandy"));
const HeatmapSeite = lazyMitVorladen(() => import("./demos/heatmap/HeatmapSeite")); const HeatmapSeite = lazyMitVorladen(() => import("./demos/heatmap/HeatmapSeite"));
const HeatmapDiagnose = lazyMitVorladen(() => import("./demos/heatmap/HeatmapDiagnose")); const HeatmapDiagnose = lazyMitVorladen(() => import("./demos/heatmap/HeatmapDiagnose"));
const PinnwandSeite = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandSeite")); const PinnwandSeite = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandSeite"));
const PinnwandHandy = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandHandy")); 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. */ /** Chunks je Demo; die Website-Chunks (Seite, Diagnose) und der Taktgeber laden gleichzeitig. */
const VORLADEN: Record<LadeArt, (() => void)[]> = { const VORLADEN: Record<LadeArt, (() => void)[]> = {
@@ -254,6 +271,7 @@ const VORLADEN: Record<LadeArt, (() => void)[]> = {
termin: [TerminSteuerung.vorladen, TerminSeite.vorladen, TerminHandy.vorladen], termin: [TerminSteuerung.vorladen, TerminSeite.vorladen, TerminHandy.vorladen],
heatmap: [HeatmapSteuerung.vorladen, HeatmapSeite.vorladen, HeatmapDiagnose.vorladen], heatmap: [HeatmapSteuerung.vorladen, HeatmapSeite.vorladen, HeatmapDiagnose.vorladen],
pinnwand: [PinnwandSteuerung.vorladen, PinnwandSeite.vorladen, PinnwandHandy.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). */ /** 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", termin: "#f6f1ea",
heatmap: "#141414", heatmap: "#141414",
pinnwand: "#f7f2e6", pinnwand: "#f7f2e6",
zeiten: "#f7f0e4",
}; };
const Platzhalter = ({ art }: { art: LadeArt }) => ( const Platzhalter = ({ art }: { art: LadeArt }) => (
<div className="kp-platzhalter" style={{ background: PLATZHALTER[art] }} aria-hidden="true" /> <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 [termin, setTermin] = useState<TerminZustand | null>(null);
const [heat, setHeat] = useState<HeatmapZustand | null>(null); const [heat, setHeat] = useState<HeatmapZustand | null>(null);
const [pinnwand, setPinnwand] = useState<PinnwandZustand | null>(null); const [pinnwand, setPinnwand] = useState<PinnwandZustand | null>(null);
const [zeiten, setZeiten] = useState<ZeitenZustand | null>(null);
useEffect(() => { useEffect(() => {
if (typeof IntersectionObserver === "undefined") return; if (typeof IntersectionObserver === "undefined") return;
@@ -488,6 +508,14 @@ const Buehne = () => {
{demoEingehaengt("pinnwand") ? ( {demoEingehaengt("pinnwand") ? (
<PinnwandSteuerung laeuft={lauf.laeuft && aktivDemo === "pinnwand"} statisch={lauf.statisch} onZustand={setPinnwand} /> <PinnwandSteuerung laeuft={lauf.laeuft && aktivDemo === "pinnwand"} statisch={lauf.statisch} onZustand={setPinnwand} />
) : null} ) : null}
{demoEingehaengt("zeiten") ? (
<ZeitenSteuerung
laeuft={lauf.laeuft && aktivDemo === "zeiten"}
statisch={lauf.statisch}
aktiv={aktivDemo === "zeiten"}
onZustand={setZeiten}
/>
) : null}
</Suspense> </Suspense>
<div className="kp-spur" aria-hidden="true"> <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 === "termin" ? <TerminSeite zustand={termin ?? undefined} variante="desktop" /> : null}
{f.demo === "heatmap" ? <HeatmapSeite zustand={heat ?? 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 === "pinnwand" ? <PinnwandSeite zustand={pinnwand ?? undefined} variante="desktop" /> : null}
{f.demo === "zeiten" ? <ZeitenSeite zustand={zeiten ?? undefined} variante="desktop" /> : null}
</Suspense> </Suspense>
) : null} ) : null}
</div> </div>
@@ -625,6 +654,7 @@ const Buehne = () => {
<Suspense fallback={null}> <Suspense fallback={null}>
{f.demo === "termin" && termin ? <TerminHandy zustand={termin} /> : null} {f.demo === "termin" && termin ? <TerminHandy zustand={termin} /> : null}
{f.demo === "pinnwand" && pinnwand ? <PinnwandHandy zustand={pinnwand} punkt={AUFHAENGEN_PUNKT} /> : null} {f.demo === "pinnwand" && pinnwand ? <PinnwandHandy zustand={pinnwand} punkt={AUFHAENGEN_PUNKT} /> : null}
{f.demo === "zeiten" && zeiten ? <ZeitenHandy zustand={zeiten} punkt={HINWEIS_PUNKT} /> : null}
</Suspense> </Suspense>
) : null} ) : null}
</div> </div>
@@ -664,6 +694,19 @@ const Buehne = () => {
</Suspense> </Suspense>
) : null} ) : null}
</div> </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) => ( {FUNKTIONEN.filter((f) => ladeArt(f) && f.hinweis).map((f) => (
<p <p
key={f.id} 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. */ /** Heatmap-Karte: Tippen auf eine Zone wählt sie, darunter erscheint die kompakte Diagnose. */
const HeatmapKarte = ({ f }: { f: Funktion }) => { const HeatmapKarte = ({ f }: { f: Funktion }) => {
const k = useKarte("heatmap"); const k = useKarte("heatmap");
@@ -825,13 +888,14 @@ const HeatmapKarte = ({ f }: { f: Funktion }) => {
const HandyReihe = () => ( const HandyReihe = () => (
<div className="kp-reihe"> <div className="kp-reihe">
<WischReihe ariaLabel="Acht Funktionen des Kundenportals, seitlich wischen"> <WischReihe ariaLabel="Neun Funktionen des Kundenportals, seitlich wischen">
{FUNKTIONEN.map((f) => { {FUNKTIONEN.map((f) => {
if (f.lang) return <LangeKarte key={f.id} f={f} />; if (f.lang) return <LangeKarte key={f.id} f={f} />;
if (f.live === "chat") return <ChatKarte 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 === "termin") return <TerminKarte key={f.id} f={f} />;
if (f.demo === "heatmap") return <HeatmapKarte 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 === "pinnwand") return <PinnwandKarte key={f.id} f={f} />;
if (f.demo === "zeiten") return <ZeitenKarte key={f.id} f={f} />;
return ( return (
<div key={f.id} className="kp-karte"> <div key={f.id} className="kp-karte">
<HandyRahmen className="kp-karte__handy" bild={handyBild(f.handy.name, f.handy.alt, "250px")} /> <HandyRahmen className="kp-karte__handy" bild={handyBild(f.handy.name, f.handy.alt, "250px")} />
+18 -1
View File
@@ -3,9 +3,10 @@ import { useTerminAblauf, type TerminZustand } from "./termin/ablauf";
import { useHeatmapAblauf, type HeatmapZustand } from "./heatmap/ablauf"; import { useHeatmapAblauf, type HeatmapZustand } from "./heatmap/ablauf";
import { usePinnwandAblauf, type PinnwandZustand } from "./pinnwand/ablauf"; import { usePinnwandAblauf, type PinnwandZustand } from "./pinnwand/ablauf";
import { useChatAblauf, type ChatZustand } from "./chat/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 * 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 * 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. * Zustand an Website, Handy-Ebene und Diagnose.
@@ -51,3 +52,19 @@ export const HeatmapSteuerung = memo(function HeatmapSteuerung({
useEffect(() => onZustand(zustand), [zustand, onZustand]); useEffect(() => onZustand(zustand), [zustand, onZustand]);
return null; 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;
+341
View File
@@ -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;
}
}
+10 -2
View File
@@ -164,7 +164,7 @@
padding: 4px 0; 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) { @media (max-height: 720px) {
/* Oberkante bleibt stehen, wenn die Unterschriften wechseln (sonst würde die zentrierte Liste springen) */ /* Oberkante bleibt stehen, wenn die Unterschriften wechseln (sonst würde die zentrierte Liste springen) */
.kp-liste { .kp-liste {
@@ -368,16 +368,22 @@
display: grid; display: grid;
} }
.kp-diagnose, .kp-diagnose,
.kp-regler,
.kp-hinweis { .kp-hinweis {
grid-area: 1 / 1; grid-area: 1 / 1;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
.kp-diagnose[data-an="ja"], .kp-diagnose[data-an="ja"],
.kp-regler[data-an="ja"],
.kp-hinweis[data-an="ja"] { .kp-hinweis[data-an="ja"] {
opacity: 1; opacity: 1;
pointer-events: auto; 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) */ /* Hinweiskarte im Stil der Diagnose (gleiche Linie, gleiches Padding) */
.kp-hinweis { .kp-hinweis {
align-self: start; align-self: start;
@@ -408,6 +414,7 @@
} }
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.kp-diagnose, .kp-diagnose,
.kp-regler,
.kp-hinweis, .kp-hinweis,
.kp-beispiel { .kp-beispiel {
transition: opacity var(--dur-enter) var(--ease); transition: opacity var(--dur-enter) var(--ease);
@@ -539,7 +546,8 @@
color: var(--fg-muted); color: var(--fg-muted);
text-wrap: balance; text-wrap: balance;
} }
.kp-karte__diagnose { .kp-karte__diagnose,
.kp-karte__regler {
width: 100%; width: 100%;
max-width: 320px; max-width: 320px;
margin-top: 1rem; margin-top: 1rem;
+2 -2
View File
@@ -176,14 +176,14 @@ describe("Quelltext", () => {
}); });
it("lädt nichts von fremden Servern", () => { 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/teile.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(/https?:\/\//);
expect(quelle(datei), datei).not.toMatch(/googleapis|gstatic|unsplash/i); expect(quelle(datei), datei).not.toMatch(/googleapis|gstatic|unsplash/i);
} }
}); });
it("bewegt nur transform und opacity und nur ohne reduzierte Bewegung", () => { 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, ""); const css = quelle(datei).replace(/\/\*[\s\S]*?\*\//g, "");
for (const treffer of css.matchAll(/@keyframes[^{]+\{([\s\S]*?)\n\}/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]); const eigenschaften = [...treffer[1].matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1]);
+516 -3
View File
@@ -1,7 +1,12 @@
import { describe, it, expect, vi, afterEach } from "vitest"; import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, renderHook, act, cleanup } from "@testing-library/react"; import { render, renderHook, act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { readFileSync } from "node:fs"; import { existsSync, readFileSync, statSync } from "node:fs";
import { Logo, Stempel, Pfeil } from "@/components/home/demos/baeckerei/teile"; 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 { import {
useZeitenAblauf, useZeitenAblauf,
statusFuer, statusFuer,
@@ -15,6 +20,7 @@ import {
HINWEIS_ANLEGEN_PUNKT, HINWEIS_ANLEGEN_PUNKT,
ZEITEN_START, ZEITEN_START,
ZEITEN_RUNDE_MS, ZEITEN_RUNDE_MS,
type ZeitenZustand,
} from "@/components/home/demos/zeiten/ablauf"; } from "@/components/home/demos/zeiten/ablauf";
// Spezifikation 2026-10-04-oeffnungszeiten-live, Abschnitt 10 (Relaunch-Station): Bäckerei Sonnenschein, Montag 5. Oktober 2026 // Spezifikation 2026-10-04-oeffnungszeiten-live, Abschnitt 10 (Relaunch-Station): Bäckerei Sonnenschein, Montag 5. Oktober 2026
@@ -377,3 +383,510 @@ describe("Quelltext", () => {
for (const name of ["Logo", "Stempel", "Pfeil"]) expect(teile).toMatch(new RegExp(`export const ${name}\\s*=`)); 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+ \}/);
});
});
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*\{/);
});
});
+58 -40
View File
@@ -5,14 +5,14 @@ import { join } from "node:path";
import Kundenportal from "@/components/home/Kundenportal"; import Kundenportal from "@/components/home/Kundenportal";
import GeraeteRahmen, { DesktopFenster, HandyRahmen } from "@/components/GeraeteRahmen"; 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 // Live-Vorschau, Chat-Demo und weiches Auf-/Zuklappen: 2026-10-04-vorschau-chat
const WURZEL = `${process.cwd()}/`; const WURZEL = `${process.cwd()}/`;
const BILDORDNER = join(WURZEL, "public/relaunch/portal"); const BILDORDNER = join(WURZEL, "public/relaunch/portal");
const TITEL = ["Live-Vorschau", "Fortschritt", "Chat", "Rechnungen", "Terminplaner", "Website-Analyse", "News-Pinnwand", "Als App"]; const TITEL = ["Live-Vorschau", "Fortschritt", "Chat", "Rechnungen", "Terminplaner", "Website-Analyse", "News-Pinnwand", "Öffnungszeiten live", "Als App"];
/** Ein Satz je Portal-Station; die drei Funktionen zeigen nur ihren Titel (Wunsch des Inhabers 2026-10-04). */ /** Ein Satz je Portal-Station; die vier Funktionen zeigen nur ihren Titel (Wunsch des Inhabers 2026-10-04). */
const UNTERSCHRIFTEN: (string | null)[] = [ const UNTERSCHRIFTEN: (string | null)[] = [
"Ihre Website, noch während wir daran bauen.", "Ihre Website, noch während wir daran bauen.",
"Was wir zuletzt erledigt haben – in einfachen Sätzen.", "Was wir zuletzt erledigt haben – in einfachen Sätzen.",
@@ -21,6 +21,7 @@ const UNTERSCHRIFTEN: (string | null)[] = [
null, null,
null, null,
null, null,
null,
"Aufs Handy installieren, fertig.", "Aufs Handy installieren, fertig.",
]; ];
const SCHRITTE = [ 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(); zeigeHandy();
expect(screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent)).toEqual(TITEL); expect(screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent)).toEqual(TITEL);
for (const text of UNTERSCHRIFTEN) if (text) expect(screen.getByText(text)).toBeInTheDocument(); for (const text of UNTERSCHRIFTEN) if (text) expect(screen.getByText(text)).toBeInTheDocument();
// die drei Funktions-Karten: nur der Titel, kein Satz und keine Stichpunkte // die vier Funktions-Karten: nur der Titel, kein Satz und keine Stichpunkte
for (const titel of ["Terminplaner", "Website-Analyse", "News-Pinnwand"]) { for (const titel of ["Terminplaner", "Website-Analyse", "News-Pinnwand", "Öffnungszeiten live"]) {
const karte = screen.getByRole("heading", { level: 3, name: titel }).closest(".kp-karte")!; const karte = screen.getByRole("heading", { level: 3, name: titel }).closest(".kp-karte")!;
expect(karte.querySelector(".kp-karte__text, ul")).toBeNull(); 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}`))); oben.forEach((titel, i) => expect(eintraege[i]).toHaveTextContent(new RegExp(`^${titel}`)));
// Portal-Stationen behalten ihren Satz // Portal-Stationen behalten ihren Satz
[0, 1, 2, 3].forEach((i) => expect(eintraege[i]).toHaveTextContent(UNTERSCHRIFTEN[i] as string)); [0, 1, 2, 3].forEach((i) => expect(eintraege[i]).toHaveTextContent(UNTERSCHRIFTEN[i] as string));
expect(eintraege[5]).toHaveTextContent(UNTERSCHRIFTEN[7] as string); expect(eintraege[5]).toHaveTextContent(UNTERSCHRIFTEN[8] as string);
// Überpunkt: zugeklappt, die drei Unterpunkte (nur Titel, keine Stichpunkte) sind versteckt // Überpunkt: zugeklappt, die vier Unterpunkte (nur Titel, keine Stichpunkte) sind versteckt
const gruppe = eintraege[4]; const gruppe = eintraege[4];
expect(gruppe).toHaveAttribute("aria-expanded", "false"); expect(gruppe).toHaveAttribute("aria-expanded", "false");
const unterliste = liste.querySelector<HTMLElement>("#kp-unterliste")!; const unterliste = liste.querySelector<HTMLElement>("#kp-unterliste")!;
expect(unterliste).toHaveAttribute("aria-label", "Funktionen"); expect(unterliste).toHaveAttribute("aria-label", "Funktionen");
expect(unterliste).not.toBeVisible(); expect(unterliste).not.toBeVisible();
const unter = within(unterliste).getAllByRole("button", { hidden: true }); 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"); 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(); zeigeDesktop();
const liste = screen.getByRole("list", { name: "Funktionen des Kundenportals" }); const liste = screen.getByRole("list", { name: "Funktionen des Kundenportals" });
const marken = liste.querySelectorAll(".kp-bald"); const marken = liste.querySelectorAll(".kp-bald");
expect(marken).toHaveLength(1); expect(marken).toHaveLength(2);
expect(marken[0].closest("button")).toHaveTextContent(/^Terminplaner/); expect(marken[0].closest("button")).toHaveTextContent(/^Terminplaner/);
expect(marken[1].closest("button")).toHaveTextContent(/^Öffnungszeiten live/);
cleanup(); cleanup();
zeigeHandy(); zeigeHandy();
const karten = screen.getAllByText("Bald verfügbar"); 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[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“", () => { 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([ it.each([
["Handy", zeigeHandy], ["Handy", zeigeHandy],
["Desktop", zeigeDesktop], ["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 { container } = zeige();
const alts = Array.from(container.querySelectorAll("img")).map((img) => img.getAttribute("alt") ?? ""); const alts = Array.from(container.querySelectorAll("img")).map((img) => img.getAttribute("alt") ?? "");
const gesamt = `${container.textContent} ${alts.join(" ")}`; 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 { container } = zeigeHandy();
const bilder = Array.from(container.querySelectorAll("img")); const bilder = Array.from(container.querySelectorAll("img"));
expect(bilder).toHaveLength(5); expect(bilder).toHaveLength(5);
@@ -276,17 +284,17 @@ describe("Kundenportal: Schrittzeile", () => {
}); });
describe("Kundenportal: Wisch-Reihen und Bedienung", () => { 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(); zeigeHandy();
const reihen = screen.getAllByRole("list").filter((l) => l.classList.contains("wk-wisch__reihe")); const reihen = screen.getAllByRole("list").filter((l) => l.classList.contains("wk-wisch__reihe"));
expect(reihen).toHaveLength(2); expect(reihen).toHaveLength(2);
for (const reihe of reihen) { for (const reihe of reihen) {
expect(reihe).toHaveAttribute("tabindex", "0"); expect(reihe).toHaveAttribute("tabindex", "0");
expect(reihe.getAttribute("aria-label")).toMatch(/wischen/); 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].children).toHaveLength(9);
expect(reihen[0].getAttribute("aria-label")).toMatch(/^Acht Funktionen/); expect(reihen[0].getAttribute("aria-label")).toMatch(/^Neun Funktionen/);
}); });
it("Desktop: keine Wisch-Reihe, dafür die Bühne", () => { 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 meldeMarke = (index: number) => {
const spur = beobachter.find((b) => b.optionen?.rootMargin === "-50% 0px -50% 0px"); const spur = beobachter.find((b) => b.optionen?.rootMargin === "-50% 0px -50% 0px");
expect(spur, "Beobachter auf der Spur aus Markern").toBeDefined(); expect(spur, "Beobachter auf der Spur aus Markern").toBeDefined();
expect(spur!.ziele).toHaveLength(8); expect(spur!.ziele).toHaveLength(9);
act(() => { act(() => {
spur!.rueckruf([{ isIntersecting: true, target: spur!.ziele[index] } as unknown as IntersectionObserverEntry], {} as IntersectionObserver); 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") .find((b) => b.getAttribute("aria-current") === "step")
?.textContent; ?.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(); beobachterAttrappe();
zeigeDesktop(); zeigeDesktop();
const gruppe = screen.getByRole("button", { name: /^Funktionen/ }); const gruppe = screen.getByRole("button", { name: /^Funktionen/ });
@@ -353,11 +361,14 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
meldeMarke(5); meldeMarke(5);
expect(aktiverEintrag()).toBe("Website-Analyse"); expect(aktiverEintrag()).toBe("Website-Analyse");
meldeMarke(7); meldeMarke(7);
expect(gruppe).toHaveAttribute("aria-expanded", "true");
expect(aktiverEintrag()).toBe("Öffnungszeiten liveBald verfügbar");
meldeMarke(8);
expect(gruppe).toHaveAttribute("aria-expanded", "false"); expect(gruppe).toHaveAttribute("aria-expanded", "false");
expect(aktiverEintrag()).toMatch(/^Als App/); 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(); beobachterAttrappe();
const { container } = zeigeDesktop(); const { container } = zeigeDesktop();
expect(aktiverEintrag()).toMatch(/^Live-Vorschau/); 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(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"); expect(container.querySelector(".kp-buehne")).toHaveAttribute("data-app", "nein");
meldeMarke(7); meldeMarke(8);
expect(aktiverEintrag()).toMatch(/^Als App/); expect(aktiverEintrag()).toMatch(/^Als App/);
expect(container.querySelector(".kp-buehne")).toHaveAttribute("data-app", "ja"); expect(container.querySelector(".kp-buehne")).toHaveAttribute("data-app", "ja");
expect(container.querySelector(".kp-fenster .kp-ebenen")).toHaveAttribute("aria-hidden", "true"); expect(container.querySelector(".kp-fenster .kp-ebenen")).toHaveAttribute("aria-hidden", "true");
// der lange Screenshot ist jetzt eingehängt und aktiv // der lange Screenshot ist jetzt eingehängt und aktiv
const lang = container.querySelector(".kp-handy .kp-lage.is-aktiv .kp-lang img"); const lang = container.querySelector(".kp-handy .kp-lage.is-aktiv .kp-lang img");
expect(lang?.getAttribute("src")).toContain("phone-2-projekte-lang"); 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) // (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(); beobachterAttrappe();
const { container } = zeigeDesktop(); const { container } = zeigeDesktop();
// zu Beginn ist noch keine Demo eingehängt // 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 meldeMarke(3); // Rechnungen: der nächste Schritt (Terminplaner) wird schon eingehängt
await waitFor(() => expect(container.querySelector(".kp-fenster .ts")).not.toBeNull()); await waitFor(() => expect(container.querySelector(".kp-fenster .ts")).not.toBeNull());
expect(container.querySelector(".kp-fenster .hm")).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(() => { await waitFor(() => {
expect(container.querySelector(".kp-fenster .hm")).not.toBeNull(); expect(container.querySelector(".kp-fenster .hm")).not.toBeNull();
expect(container.querySelector(".kp-fenster .pw-seite")).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 // 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")); const lagen = Array.from(container.querySelectorAll(".kp-fenster .kp-lage"));
expect(lagen.filter((l) => l.classList.contains("is-aktiv"))).toHaveLength(1); expect(lagen.filter((l) => l.classList.contains("is-aktiv"))).toHaveLength(1);
expect(container.querySelector(".kp-fenster .kp-lage.is-aktiv .pw-seite")).not.toBeNull(); 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 // 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("aria-hidden", "true");
expect(wurzel).toHaveAttribute("data-phase"); expect(wurzel).toHaveAttribute("data-phase");
expect(wurzel.querySelectorAll("a, button, input, select, textarea, [tabindex]")).toHaveLength(0); 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"); expect(adresse()).toBe("boulderhalle-ueberhang.example");
meldeMarke(6); meldeMarke(6);
expect(adresse()).toBe("musterbach.example"); expect(adresse()).toBe("musterbach.example");
meldeMarke(7);
expect(adresse()).toBe("baeckerei-sonnenschein.example");
meldeMarke(2); meldeMarke(2);
expect(adresse()).toBe("project.webklar.com"); expect(adresse()).toBe("project.webklar.com");
await waitFor(() => expect(container.querySelector(".kp-fenster .ts")).not.toBeNull()); 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 // mit Maus (diese Bühne meldet eine feine Zeigereingabe): Hinweis zum Zeigen
"Erfundenes Beispiel · Zeigen Sie mit der Maus auf die Seite.", "Erfundenes Beispiel · Zeigen Sie mit der Maus auf die Seite.",
"Erfundenes Beispiel · musterbach.example", "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); meldeMarke(6);
expect(diagnose()).toHaveAttribute("data-an", "nein"); expect(diagnose()).toHaveAttribute("data-an", "nein");
expect(diagnose()).toHaveAttribute("inert"); 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(); beobachterAttrappe();
const { container } = zeigeDesktop(); const { container } = zeigeDesktop();
const hinweise = () => Array.from(container.querySelectorAll(".kp-hinweis")) as HTMLElement[]; 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"]); expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "nein"]);
meldeMarke(6); meldeMarke(6);
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "ja"]); 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 () => { 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 gruppe = screen.getByRole("button", { name: /^Funktionen/ });
const liste = screen.getByRole("list", { name: "Funktionen des Kundenportals" }); const liste = screen.getByRole("list", { name: "Funktionen des Kundenportals" });
const unterliste = liste.querySelector<HTMLElement>("#kp-unterliste")!; const unterliste = liste.querySelector<HTMLElement>("#kp-unterliste")!;
meldeMarke(6);
expect(gruppe).toHaveAttribute("aria-expanded", "true");
meldeMarke(7); meldeMarke(7);
expect(gruppe).toHaveAttribute("aria-expanded", "true");
meldeMarke(8);
// sofort zu für Screenreader, sichtbar faltet sich die Unterliste noch ein // sofort zu für Screenreader, sichtbar faltet sich die Unterliste noch ein
expect(gruppe).toHaveAttribute("aria-expanded", "false"); expect(gruppe).toHaveAttribute("aria-expanded", "false");
expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "schliesst"); 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"); expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "zu");
// geht sie während des Einfaltens wieder auf, bleibt sie offen // geht sie während des Einfaltens wieder auf, bleibt sie offen
meldeMarke(5); meldeMarke(5);
meldeMarke(7); meldeMarke(8);
meldeMarke(5); meldeMarke(5);
expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "offen"); expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "offen");
await new Promise((r) => setTimeout(r, 320)); 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(); 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(); beobachterAttrappe();
const { container } = zeigeHandy(); const { container } = zeigeHandy();
// drei Funktions-Karten (Websites) und die Chat-Karte // vier Funktions-Karten (Websites) und die Chat-Karte
expect(container.querySelectorAll(".kp-karte--demo")).toHaveLength(4); expect(container.querySelectorAll(".kp-karte--demo")).toHaveLength(5);
const karten = Array.from(container.querySelectorAll(".kp-karte--demo[data-demo]")); 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 // 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 // jede Karte meldet sich über ihre Beobachter als sichtbar
act(() => { act(() => {
for (const b of beobachter) { 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); 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)); 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)[data-variante='handy']"))) 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"); expect(wurzel).toHaveAttribute("aria-hidden", "true");
// Heatmap-Karte: kompakte Diagnose darunter, Tippen auf eine Zone wählt sie // Heatmap-Karte: kompakte Diagnose darunter, Tippen auf eine Zone wählt sie
const heat = container.querySelector(".kp-karte--demo[data-demo='heatmap']") as HTMLElement; const heat = container.querySelector(".kp-karte--demo[data-demo='heatmap']") as HTMLElement;