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:
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.
|
||||
- Stattdessen eine **Wisch-Reihe** (9.4) mit je einem Handy-Screenshot im Rahmen und der Bildunterschrift darunter.
|
||||
- Die letzte Karte „Als App“ zeigt `phone-2-projekte-lang`. Es schiebt sich langsam nach oben, solange die Karte sichtbar ist.
|
||||
- Bei O9 = ja höchstens 7 Karten.
|
||||
- Bei O9 = ja höchstens 9 Karten (Stand mit „Öffnungszeiten live“: die Funktions-Reihe hat neun Stationen).
|
||||
|
||||
**Schrittzeile unter dem Portal** (ersetzt „So arbeiten wir“):
|
||||
- Text: „**1** Kennenlernen · **2** Angebot mit Preis · **3** Bauen mit Vorschau und Ihrer Freigabe · **4** Online – Hosting und Pflege auf Wunsch im Abo“
|
||||
@@ -496,7 +496,7 @@ Vor jedem Löschen prüfen, dass nichts mehr die Datei importiert.
|
||||
- Die Fokusreihenfolge entspricht der Anzeige.
|
||||
- Kein `aria-live`, keine automatische Bewegung.
|
||||
- **Ab 900 px** zeigt jeder Abschnitt seine Desktop-Darstellung (Liste, Raster oder Bühne). Auf dem Desktop bleibt keine Wisch-Reihe übrig.
|
||||
- **Grenze:** Höchstens 7 Karten je Reihe.
|
||||
- **Grenze:** Höchstens 9 Karten je Reihe (Stand mit „Öffnungszeiten live“; vorher 7).
|
||||
|
||||
### 9.5 Bilder
|
||||
- **Format und Auslieferung:**
|
||||
|
||||
@@ -130,7 +130,7 @@ reagiert auf Tippen (Zone antippen ⇒ Diagnose unter der Karte).
|
||||
möglichst als eigener Chunk (`React.lazy`), damit die Startseite nicht schwerer wird.
|
||||
- Inaktive Ebenen der Bühne bekommen `pointer-events: none`, damit nur die sichtbare Website auf die Maus reagiert.
|
||||
- Tests: Vitest je Demo (Rendern Desktop/Handy, statischer Endzustand ohne Bewegung, Diagnose-Logik,
|
||||
keine fokussierbaren Elemente in der Website); `relaunch-kundenportal.test.tsx` auf 8 Stationen angepasst.
|
||||
keine fokussierbaren Elemente in der Website); `relaunch-kundenportal.test.tsx` auf 8 Stationen angepasst (seit „Öffnungszeiten live“: 9 Stationen).
|
||||
`sofort-paket.test.tsx` (Scans gegen verbotene Aussagen) bleibt grün.
|
||||
|
||||
## 5. Offen für den Inhaber
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -18,6 +18,7 @@ import type { TerminZustand } from "./demos/termin/ablauf";
|
||||
import type { HeatmapZustand } from "./demos/heatmap/ablauf";
|
||||
import type { PinnwandZustand } from "./demos/pinnwand/ablauf";
|
||||
import type { ChatZustand } from "./demos/chat/ablauf";
|
||||
import type { ZeitenZustand } from "./demos/zeiten/ablauf";
|
||||
import "./kundenportal.css";
|
||||
|
||||
/**
|
||||
@@ -25,7 +26,7 @@ import "./kundenportal.css";
|
||||
* Ab 900 px: festgepinnte Bühne mit Funktionsliste links und Desktop-Fenster plus Handy-Rahmen rechts.
|
||||
* Darunter (unter 900 px): keine Bühne, sondern eine Wisch-Reihe mit je einem Handy-Screenshot.
|
||||
* Danach die Schrittzeile (ersetzt "So arbeiten wir").
|
||||
* Die Stationen 5 bis 7 (Terminplaner, Website-Analyse, News-Pinnwand) zeigen statt eines Bildes eine
|
||||
* Die Stationen 5 bis 8 (Terminplaner, Website-Analyse, News-Pinnwand, Öffnungszeiten live) zeigen statt eines Bildes eine
|
||||
* Live-Demo: auf dem MacBook die erfundene Kunden-Website, auf dem iPhone das Portal (Spezifikation
|
||||
* 2026-10-04-funktionen-demos). Die Demos werden per React.lazy erst geladen, wenn die Bühne ihre Station fast erreicht.
|
||||
* Live sind außerdem (Wunsch des Inhabers 2026-10-04, Spezifikation 2026-10-04-vorschau-chat):
|
||||
@@ -67,7 +68,7 @@ const handyBild = (name: string, alt: string, sizes: string, hoehe = 1696): Rahm
|
||||
alt,
|
||||
});
|
||||
|
||||
type DemoArt = "termin" | "heatmap" | "pinnwand";
|
||||
type DemoArt = "termin" | "heatmap" | "pinnwand" | "zeiten";
|
||||
/** Live-Stationen außerhalb der Gruppe „Funktionen“ */
|
||||
type LiveArt = "vorschau" | "chat";
|
||||
type LadeArt = DemoArt | LiveArt;
|
||||
@@ -94,9 +95,9 @@ type Funktion = {
|
||||
adresse?: string;
|
||||
/** Beschreibung der Beispiel-Website für Screenreader (die Website selbst ist aria-hidden). */
|
||||
beschreibung?: string;
|
||||
/** Hinweis unter dem MacBook (Live-Vorschau, Chat, Terminplaner, Pinnwand; bei der Website-Analyse steht dort die Diagnose). */
|
||||
/** Hinweis unter dem MacBook (Live-Vorschau, Chat, Terminplaner, Pinnwand; bei der Website-Analyse steht dort die Diagnose, bei Öffnungszeiten live der Uhr-Regler). */
|
||||
hinweis?: string;
|
||||
/** Noch nicht für Kunden buchbar: Marke „Bald verfügbar“ am Titel (Terminplaner läuft bisher nur im Pilot, Stand 04.10.2026). */
|
||||
/** Noch nicht für Kunden buchbar: Marke „Bald verfügbar“ am Titel (Terminplaner läuft bisher nur im Pilot, Stand 04.10.2026; Öffnungszeiten live bis zum Portal-Livegang). */
|
||||
bald?: boolean;
|
||||
};
|
||||
|
||||
@@ -104,6 +105,8 @@ const BaldMarke = ({ f }: { f: Funktion }) => (f.bald ? <span className="kp-bald
|
||||
|
||||
/** Mitte des Knopfes "Aufhängen" im Portal-Screenshot der News-Pinnwand (Designpunkte der iPhone-Fläche 402 x 874). */
|
||||
const AUFHAENGEN_PUNKT = { x: 201, y: 613 };
|
||||
/** Mitte des Knopfes "Hinweis-Leiste anlegen" im Portal-Screenshot der Station Öffnungszeiten live (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,
|
||||
// weitere Ansichten nur nach Freigabe des Inhabers (O9 = nein).
|
||||
@@ -191,6 +194,16 @@ const FUNKTIONEN: Funktion[] = [
|
||||
alt: "Kundenportal von WEBklar auf dem Handy: Ein neuer Zettel für die News-Pinnwand mit Überschrift und Text ist ausgefüllt, darunter der Knopf zum Aufhängen auf der Website.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "oeffnungszeiten",
|
||||
titel: "Öffnungszeiten live",
|
||||
demo: "zeiten",
|
||||
bald: true,
|
||||
adresse: "baeckerei-sonnenschein.example",
|
||||
beschreibung:
|
||||
"Beispiel-Website einer Bäckerei mit Öffnungszeiten: Der Status „Jetzt geöffnet“ folgt der Uhrzeit, danach kündigt eine Hinweis-Leiste den Betriebsurlaub an",
|
||||
handy: { name: "phone-12-zeiten", alt: "Öffnungszeiten im Kundenportal bearbeiten" },
|
||||
},
|
||||
{
|
||||
id: "als-app",
|
||||
titel: "Als App",
|
||||
@@ -239,12 +252,16 @@ const ChatHandy = lazyMitVorladen(() => import("./demos/chat/ChatHandy"));
|
||||
const TerminSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.TerminSteuerung })));
|
||||
const HeatmapSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.HeatmapSteuerung })));
|
||||
const PinnwandSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.PinnwandSteuerung })));
|
||||
const ZeitenSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.ZeitenSteuerung })));
|
||||
const TerminSeite = lazyMitVorladen(() => import("./demos/termin/TerminSeite"));
|
||||
const TerminHandy = lazyMitVorladen(() => import("./demos/termin/TerminHandy"));
|
||||
const HeatmapSeite = lazyMitVorladen(() => import("./demos/heatmap/HeatmapSeite"));
|
||||
const HeatmapDiagnose = lazyMitVorladen(() => import("./demos/heatmap/HeatmapDiagnose"));
|
||||
const PinnwandSeite = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandSeite"));
|
||||
const PinnwandHandy = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandHandy"));
|
||||
const ZeitenSeite = lazyMitVorladen(() => import("./demos/zeiten/ZeitenSeite"));
|
||||
const ZeitenHandy = lazyMitVorladen(() => import("./demos/zeiten/ZeitenHandy"));
|
||||
const ZeitenRegler = lazyMitVorladen(() => import("./demos/zeiten/ZeitenRegler"));
|
||||
|
||||
/** Chunks je Demo; die Website-Chunks (Seite, Diagnose) und der Taktgeber laden gleichzeitig. */
|
||||
const VORLADEN: Record<LadeArt, (() => void)[]> = {
|
||||
@@ -254,6 +271,7 @@ const VORLADEN: Record<LadeArt, (() => void)[]> = {
|
||||
termin: [TerminSteuerung.vorladen, TerminSeite.vorladen, TerminHandy.vorladen],
|
||||
heatmap: [HeatmapSteuerung.vorladen, HeatmapSeite.vorladen, HeatmapDiagnose.vorladen],
|
||||
pinnwand: [PinnwandSteuerung.vorladen, PinnwandSeite.vorladen, PinnwandHandy.vorladen],
|
||||
zeiten: [ZeitenSteuerung.vorladen, ZeitenSeite.vorladen, ZeitenHandy.vorladen, ZeitenRegler.vorladen],
|
||||
};
|
||||
|
||||
/** Hintergrund der Beispiel-Websites, solange ihr Chunk lädt (statt einer schwarzen Fläche im MacBook). */
|
||||
@@ -263,6 +281,7 @@ const PLATZHALTER: Record<LadeArt, string> = {
|
||||
termin: "#f6f1ea",
|
||||
heatmap: "#141414",
|
||||
pinnwand: "#f7f2e6",
|
||||
zeiten: "#f7f0e4",
|
||||
};
|
||||
const Platzhalter = ({ art }: { art: LadeArt }) => (
|
||||
<div className="kp-platzhalter" style={{ background: PLATZHALTER[art] }} aria-hidden="true" />
|
||||
@@ -331,6 +350,7 @@ const Buehne = () => {
|
||||
const [termin, setTermin] = useState<TerminZustand | null>(null);
|
||||
const [heat, setHeat] = useState<HeatmapZustand | null>(null);
|
||||
const [pinnwand, setPinnwand] = useState<PinnwandZustand | null>(null);
|
||||
const [zeiten, setZeiten] = useState<ZeitenZustand | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof IntersectionObserver === "undefined") return;
|
||||
@@ -488,6 +508,14 @@ const Buehne = () => {
|
||||
{demoEingehaengt("pinnwand") ? (
|
||||
<PinnwandSteuerung laeuft={lauf.laeuft && aktivDemo === "pinnwand"} statisch={lauf.statisch} onZustand={setPinnwand} />
|
||||
) : null}
|
||||
{demoEingehaengt("zeiten") ? (
|
||||
<ZeitenSteuerung
|
||||
laeuft={lauf.laeuft && aktivDemo === "zeiten"}
|
||||
statisch={lauf.statisch}
|
||||
aktiv={aktivDemo === "zeiten"}
|
||||
onZustand={setZeiten}
|
||||
/>
|
||||
) : null}
|
||||
</Suspense>
|
||||
|
||||
<div className="kp-spur" aria-hidden="true">
|
||||
@@ -596,6 +624,7 @@ const Buehne = () => {
|
||||
{f.demo === "termin" ? <TerminSeite zustand={termin ?? undefined} variante="desktop" /> : null}
|
||||
{f.demo === "heatmap" ? <HeatmapSeite zustand={heat ?? undefined} variante="desktop" /> : null}
|
||||
{f.demo === "pinnwand" ? <PinnwandSeite zustand={pinnwand ?? undefined} variante="desktop" /> : null}
|
||||
{f.demo === "zeiten" ? <ZeitenSeite zustand={zeiten ?? undefined} variante="desktop" /> : null}
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -625,6 +654,7 @@ const Buehne = () => {
|
||||
<Suspense fallback={null}>
|
||||
{f.demo === "termin" && termin ? <TerminHandy zustand={termin} /> : null}
|
||||
{f.demo === "pinnwand" && pinnwand ? <PinnwandHandy zustand={pinnwand} punkt={AUFHAENGEN_PUNKT} /> : null}
|
||||
{f.demo === "zeiten" && zeiten ? <ZeitenHandy zustand={zeiten} punkt={HINWEIS_PUNKT} /> : null}
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -664,6 +694,19 @@ const Buehne = () => {
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Öffnungszeiten live: der Uhr-Regler (echter Schieberegler), nur bei aktiver Station sichtbar und bedienbar */}
|
||||
<div
|
||||
className="kp-regler"
|
||||
data-an={aktivDemo === "zeiten" ? "ja" : "nein"}
|
||||
aria-hidden={aktivDemo !== "zeiten" || undefined}
|
||||
{...(aktivDemo !== "zeiten" ? INERT : null)}
|
||||
>
|
||||
{demoEingehaengt("zeiten") ? (
|
||||
<Suspense fallback={null}>
|
||||
<ZeitenRegler zustand={zeiten ?? undefined} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
{FUNKTIONEN.filter((f) => ladeArt(f) && f.hinweis).map((f) => (
|
||||
<p
|
||||
key={f.id}
|
||||
@@ -803,6 +846,26 @@ const PinnwandKarte = ({ f }: { f: Funktion }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Öffnungszeiten-Karte: die Website im Handy, darunter der Uhr-Regler (Ziehen ändert Status und Uhrzeit auf der Website). */
|
||||
const ZeitenKarte = ({ f }: { f: Funktion }) => {
|
||||
const k = useKarte("zeiten");
|
||||
const [z, setZ] = useState<ZeitenZustand | null>(null);
|
||||
return (
|
||||
<DemoKarteRahmen
|
||||
f={f}
|
||||
geraetRef={k.ref}
|
||||
nachher={
|
||||
<div className="kp-karte__regler">
|
||||
<Suspense fallback={null}>{k.gesehen ? <ZeitenRegler zustand={z ?? undefined} kompakt /> : null}</Suspense>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{k.gesehen ? <ZeitenSteuerung laeuft={k.laeuft} statisch={k.statisch} aktiv onZustand={setZ} /> : null}
|
||||
{k.gesehen ? <ZeitenSeite zustand={z ?? undefined} variante="handy" /> : null}
|
||||
</DemoKarteRahmen>
|
||||
);
|
||||
};
|
||||
|
||||
/** Heatmap-Karte: Tippen auf eine Zone wählt sie, darunter erscheint die kompakte Diagnose. */
|
||||
const HeatmapKarte = ({ f }: { f: Funktion }) => {
|
||||
const k = useKarte("heatmap");
|
||||
@@ -825,13 +888,14 @@ const HeatmapKarte = ({ f }: { f: Funktion }) => {
|
||||
|
||||
const HandyReihe = () => (
|
||||
<div className="kp-reihe">
|
||||
<WischReihe ariaLabel="Acht Funktionen des Kundenportals, seitlich wischen">
|
||||
<WischReihe ariaLabel="Neun Funktionen des Kundenportals, seitlich wischen">
|
||||
{FUNKTIONEN.map((f) => {
|
||||
if (f.lang) return <LangeKarte key={f.id} f={f} />;
|
||||
if (f.live === "chat") return <ChatKarte key={f.id} f={f} />;
|
||||
if (f.demo === "termin") return <TerminKarte key={f.id} f={f} />;
|
||||
if (f.demo === "heatmap") return <HeatmapKarte key={f.id} f={f} />;
|
||||
if (f.demo === "pinnwand") return <PinnwandKarte key={f.id} f={f} />;
|
||||
if (f.demo === "zeiten") return <ZeitenKarte key={f.id} f={f} />;
|
||||
return (
|
||||
<div key={f.id} className="kp-karte">
|
||||
<HandyRahmen className="kp-karte__handy" bild={handyBild(f.handy.name, f.handy.alt, "250px")} />
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useTerminAblauf, type TerminZustand } from "./termin/ablauf";
|
||||
import { useHeatmapAblauf, type HeatmapZustand } from "./heatmap/ablauf";
|
||||
import { usePinnwandAblauf, type PinnwandZustand } from "./pinnwand/ablauf";
|
||||
import { useChatAblauf, type ChatZustand } from "./chat/ablauf";
|
||||
import { useZeitenAblauf, type ZeitenZustand } from "./zeiten/ablauf";
|
||||
|
||||
/**
|
||||
* Taktgeber der Demos (drei Funktionen und der Chat) als unsichtbare Bauteile (rendern nichts, melden nur ihren Zustand).
|
||||
* Taktgeber der Demos (vier Funktionen und der Chat) als unsichtbare Bauteile (rendern nichts, melden nur ihren Zustand).
|
||||
* Sie liegen in einem eigenen Chunk, damit die Startseite die Ablauf-Logik erst lädt, wenn die Kundenportal-Bühne
|
||||
* eine Demo-Station fast erreicht (Spezifikation 4). Die Bühne hält den gemeldeten Zustand und gibt denselben
|
||||
* Zustand an Website, Handy-Ebene und Diagnose.
|
||||
@@ -51,3 +52,19 @@ export const HeatmapSteuerung = memo(function HeatmapSteuerung({
|
||||
useEffect(() => onZustand(zustand), [zustand, onZustand]);
|
||||
return null;
|
||||
});
|
||||
|
||||
/** `aktiv`: Station ist die aktive; sonst geht eine Bedienung des Uhr-Reglers zurück an den Ablauf (beim nächsten Mal von vorn). */
|
||||
export const ZeitenSteuerung = memo(function ZeitenSteuerung({
|
||||
laeuft,
|
||||
statisch,
|
||||
aktiv,
|
||||
onZustand,
|
||||
}: Props<ZeitenZustand> & { aktiv: boolean }) {
|
||||
const zustand = useZeitenAblauf(laeuft, statisch);
|
||||
const { setzeUhr } = zustand;
|
||||
useEffect(() => {
|
||||
if (!aktiv) setzeUhr(null);
|
||||
}, [aktiv, setzeUhr]);
|
||||
useEffect(() => onZustand(zustand), [zustand, onZustand]);
|
||||
return null;
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { DemoZeiger } from "../gemeinsam";
|
||||
import { HINWEIS_ANLEGEN_PUNKT, type ZeitenZustand } from "./ablauf";
|
||||
|
||||
/** Wo der Finger wartet, bevor er zum Knopf gleitet (unten rechts, neben dem Home-Balken). */
|
||||
const RUHE = { x: 318, y: 842 };
|
||||
|
||||
// Inline, damit die Ebene die Fläche genau überdeckt und nichts anklickt, auch ohne geladenes CSS.
|
||||
const EBENE = { position: "absolute", inset: 0, pointerEvents: "none", overflow: "hidden" } as const;
|
||||
|
||||
type Props = {
|
||||
zustand: ZeitenZustand;
|
||||
/** Mitte des Knopfes „Hinweis-Leiste anlegen“ im Portal-Bild; Standard: HINWEIS_ANLEGEN_PUNKT. */
|
||||
punkt?: { x: number; y: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* Live-Ebene über dem Portal-Screenshot im iPhone der Bühne (402 x 874, transparent, pointer-events none):
|
||||
* in Phase „tippen“ gleitet ein Finger auf „Hinweis-Leiste anlegen“ und tippt; danach gleitet auf der Website die Leiste ein.
|
||||
* Davor (Uhr läuft) wartet der Finger unsichtbar in der Ruhelage, damit er beim Start von dort zum Knopf gleitet.
|
||||
*/
|
||||
const ZeitenHandy = ({ zustand, punkt = HINWEIS_ANLEGEN_PUNKT }: Props) => {
|
||||
const amKnopf = zustand.phase === "tippen" || zustand.phase === "leiste";
|
||||
const ziel = amKnopf ? punkt : RUHE;
|
||||
return (
|
||||
<div style={EBENE} aria-hidden="true" data-phase={zustand.phase}>
|
||||
<DemoZeiger x={ziel.x} y={ziel.y} art="finger" klick={zustand.klick} sichtbar={zustand.phase === "tippen"} groesse={1.5} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZeitenHandy;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useId } from "react";
|
||||
import { ZEITEN_START, ZEIT_MAX, ZEIT_MIN, ZEIT_SCHRITT, formatUhr, type ZeitenZustand } from "./ablauf";
|
||||
import "./zeiten.css";
|
||||
|
||||
/**
|
||||
* Der Uhr-Regler unter dem MacBook (Station „Öffnungszeiten live“): ein echter Schieberegler (input type=range) mit Label,
|
||||
* der Uhrzeit und dem Statussatz der Bäckerei zu dieser Uhrzeit. Wer ihn anfasst, hält den Automatiklauf an;
|
||||
* die Website zeigt dann den Status der gewählten Uhrzeit. Normaler Seitenmaßstab mit den Seitentoken, hell und dunkel.
|
||||
* `kompakt` ist für die Handy-Karte (schmal, bis 320 px).
|
||||
* Der Statussatz meldet sich Screenreadern (aria-live) nur nach einer Bedienung, nicht beim Automatiklauf.
|
||||
*/
|
||||
|
||||
type Props = { zustand?: ZeitenZustand; kompakt?: boolean };
|
||||
|
||||
const ZeitenRegler = ({ zustand = ZEITEN_START, kompakt = false }: Props) => {
|
||||
const id = useId();
|
||||
const { uhr, status, bedient } = zustand;
|
||||
return (
|
||||
<div className={`zr${kompakt ? " zr--kompakt" : ""}`} data-bedient={bedient ? "ja" : "nein"}>
|
||||
<div className="zr__kopf">
|
||||
<label className="zr__titel" htmlFor={id}>
|
||||
Uhrzeit testen
|
||||
</label>
|
||||
<span className="zr__uhr" aria-hidden="true">
|
||||
{formatUhr(uhr)}
|
||||
{" "}Uhr
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id={id}
|
||||
className="zr__regler"
|
||||
type="range"
|
||||
min={ZEIT_MIN}
|
||||
max={ZEIT_MAX}
|
||||
step={ZEIT_SCHRITT}
|
||||
value={uhr}
|
||||
aria-valuetext={`${formatUhr(uhr)} Uhr, ${status.kurz}`}
|
||||
onChange={(e) => zustand.setzeUhr(Number(e.target.value))}
|
||||
/>
|
||||
<p className="zr__status" data-offen={status.offen ? "ja" : "nein"} aria-live={bedient ? "polite" : "off"} aria-atomic="true">
|
||||
<i className="zr__punkt" aria-hidden="true" />
|
||||
{status.text}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZeitenRegler;
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { DemoVariante } from "../gemeinsam";
|
||||
import { Logo, Pfeil, Stempel } from "../baeckerei/teile";
|
||||
import { HINWEIS_TEXT, ZEITEN_START, type ZeitenZustand } from "./ablauf";
|
||||
import "../baeckerei/baeckerei.css";
|
||||
import "./zeiten.css";
|
||||
|
||||
/**
|
||||
* Die Beispiel-Website der Station „Öffnungszeiten live“: wieder die erfundene „Bäckerei Sonnenschein“ (Spezifikation 10),
|
||||
* hier mit großer Status-Pille („Jetzt geöffnet · schließt um 18 Uhr“) und der Hinweis-Leiste „Betriebsurlaub 12.–26. Oktober“,
|
||||
* die oben einschiebt, sobald der Inhaber sie im Portal anlegt (Phase „leiste“; die Seite rückt per transform nach).
|
||||
* Demo-Datum: Montag, 5. Oktober 2026 (Mo bis Fr 6 bis 18 Uhr), die Uhrzeit kommt aus dem Zustand.
|
||||
* Desktop = 1512 x 893 (MacBook-Fläche), Handy = 402 x 874 (oben 59 / unten 34 frei für Statusleiste und Home-Balken).
|
||||
* Eigenes Wurzelelement ohne .bk-inhalt: die Seite läuft nicht mit dem Scrollfortschritt der Live-Vorschau mit.
|
||||
* Die Website ist reine Kulisse: aria-hidden, keine fokussierbaren Elemente (div/span statt a/button).
|
||||
*/
|
||||
|
||||
const FOTO = "/relaunch/demos/baeckerei/roggenbrot-600.webp";
|
||||
|
||||
/** Wie in der Live-Vorschau: Montag bis Freitag 6 bis 18 Uhr; der Demo-Tag ist ein Montag. */
|
||||
const ZEITEN = [
|
||||
{ tage: "Mo – Fr", zeit: "6:00 – 18:00 Uhr", heute: true },
|
||||
{ tage: "Sa", zeit: "6:00 – 13:00 Uhr" },
|
||||
{ tage: "So", zeit: "7:00 – 11:00 Uhr" },
|
||||
];
|
||||
|
||||
const MENUE = ["Sortiment", "Backstube", "Café", "Torten", "Kontakt"];
|
||||
const BANDWOERTER = ["Roggenbrot", "Dinkelvollkorn", "Buttercroissant", "Laugenbrezel", "Zimtschnecke", "Zwetschgenkuchen"];
|
||||
|
||||
const Karte = () => (
|
||||
<div className="zt-karte">
|
||||
<span className="bk-dach">Öffnungszeiten</span>
|
||||
{ZEITEN.map((z) => (
|
||||
<div key={z.tage} className={`zt-zeile${z.heute ? " is-heute" : ""}`}>
|
||||
<span>{z.tage}</span>
|
||||
<span>{z.zeit}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** `zustand` fehlt, solange der Taktgeber noch lädt: dann steht die Website im Anfangszustand da. */
|
||||
type Props = { zustand?: ZeitenZustand; variante: DemoVariante };
|
||||
|
||||
/** Die Website auf der MacBook-Fläche (desktop) oder in der Handy-Fassung (handy). */
|
||||
const ZeitenSeite = ({ zustand = ZEITEN_START, variante }: Props) => {
|
||||
const desktop = variante === "desktop";
|
||||
const { status } = zustand;
|
||||
return (
|
||||
<div
|
||||
className="bk-seite zt-seite"
|
||||
lang="de"
|
||||
aria-hidden="true"
|
||||
data-variante={variante}
|
||||
data-phase={zustand.phase}
|
||||
data-laeuft={zustand.laeuft ? "ja" : "nein"}
|
||||
>
|
||||
<div className="zt-leiste">
|
||||
<i className="zt-leiste__punkt" />
|
||||
{HINWEIS_TEXT}
|
||||
</div>
|
||||
|
||||
<div className="zt-inhalt">
|
||||
<div className="bk-kopf zt-kopf">
|
||||
<div className="bk-logo">
|
||||
<Logo />
|
||||
<span className="bk-logo__text">
|
||||
<span className="bk-logo__name">Sonnenschein</span>
|
||||
<span className="bk-logo__zusatz">Bäckerei · seit 1987</span>
|
||||
</span>
|
||||
</div>
|
||||
{desktop ? (
|
||||
<>
|
||||
<div className="bk-menue">
|
||||
{MENUE.map((m) => (
|
||||
<span key={m}>{m}</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="bk-knopf bk-knopf--dunkel bk-knopf--klein">Vorbestellen</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="zt-burger">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="zt-held">
|
||||
<div className="zt-held__text">
|
||||
<div className="zt-pille" data-offen={status.offen ? "ja" : "nein"}>
|
||||
<i className="zt-pille__punkt" />
|
||||
{/* der Text wechselt nur, wenn sich der Status ändert; mit Bewegung blendet er dann neu ein */}
|
||||
<span key={status.text} className="zt-pille__text">
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bk-held__titel zt-titel">
|
||||
Frisch gebacken.
|
||||
<br />
|
||||
<em>Jeden Morgen.</em>
|
||||
</div>
|
||||
<p className="bk-held__lead">
|
||||
Brot, Brötchen und Kuchen aus unserer Backstube in der Altstadt – mit Sauerteig, viel Zeit und Mehl aus der Region.
|
||||
</p>
|
||||
{desktop ? (
|
||||
<div className="bk-held__knoepfe">
|
||||
<span className="bk-knopf bk-knopf--dunkel">
|
||||
Sortiment ansehen <Pfeil />
|
||||
</span>
|
||||
<span className="bk-knopf bk-knopf--rand">Torte bestellen</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{desktop ? (
|
||||
<div className="bk-held__bild">
|
||||
<img src={FOTO} width={600} height={556} alt="" decoding="async" />
|
||||
<Karte />
|
||||
<Stempel />
|
||||
</div>
|
||||
) : (
|
||||
<Karte />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{desktop ? (
|
||||
<div className="bk-band">
|
||||
{BANDWOERTER.map((w) => (
|
||||
<span key={w}>{w}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZeitenSeite;
|
||||
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* Station „Öffnungszeiten live“: Beispiel-Website der Bäckerei Sonnenschein mit großer Status-Pille (.zt-*) und der Uhr-Regler
|
||||
* unter dem MacBook (.zr-*). Grundlage ist baeckerei.css (.bk-seite mit den Variablen --bk-*, Logo, Menü, Knöpfe); die Seite hat
|
||||
* ein eigenes Wurzelelement ohne .bk-inhalt, sonst liefe sie mit --kp-v (Scrollfortschritt der Live-Vorschau) davon.
|
||||
* Desktop 1512 x 893, Handy 402 x 874 (oben 59 / unten 34 frei).
|
||||
* Im MacBook liegt das iPhone über der Fläche ab etwa x 1150 und y 410: dort steht nichts Wichtiges (Pille und Öffnungszeiten
|
||||
* links davon, die Hinweis-Leiste oben, oberhalb von y 410). Größen in Designpunkten: das MacBook verkleinert auf etwa 46 %,
|
||||
* darum Pille ≥ 30 und Leiste ≥ 24.
|
||||
* Grundzustand statisch; Bewegung nur transform/opacity hinter prefers-reduced-motion: no-preference.
|
||||
*/
|
||||
|
||||
.zt-seite {
|
||||
--zt-leiste-h: 64px;
|
||||
--zt-offen: #3f5e2c;
|
||||
--zt-offen-punkt: #4c9a3c;
|
||||
--zt-offen-ring: #dcebd3;
|
||||
--zt-zu: #8a3a12;
|
||||
--zt-zu-punkt: #c4622d;
|
||||
--zt-zu-ring: #f4dccb;
|
||||
}
|
||||
|
||||
/* ── Hinweis-Leiste: gleitet oben ein, der Inhalt rückt per transform nach ─────────────────────────── */
|
||||
|
||||
.zt-leiste {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
background: var(--bk-tinte);
|
||||
color: #f6ead6;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.zt-leiste__punkt {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--bk-sonne);
|
||||
box-shadow: 0 0 0 5px rgb(242 181 68 / 0.28);
|
||||
}
|
||||
.zt-inhalt {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.zt-seite[data-phase="leiste"] .zt-leiste {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.zt-seite[data-phase="leiste"] .zt-inhalt {
|
||||
transform: translateY(var(--zt-leiste-h));
|
||||
}
|
||||
|
||||
/* ── Start: Pille, Titel, Öffnungszeiten ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.zt-held {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 560px;
|
||||
gap: 56px;
|
||||
align-items: center;
|
||||
height: 640px;
|
||||
padding: 0 88px;
|
||||
}
|
||||
.zt-held__text {
|
||||
min-width: 0;
|
||||
}
|
||||
.zt-pille {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 28px 12px 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--bk-papier);
|
||||
border: 2px solid var(--bk-linie);
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
color: var(--zt-offen);
|
||||
}
|
||||
.zt-pille__punkt {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--zt-offen-punkt);
|
||||
box-shadow: 0 0 0 6px var(--zt-offen-ring);
|
||||
}
|
||||
.zt-pille[data-offen="nein"] {
|
||||
color: var(--zt-zu);
|
||||
}
|
||||
.zt-pille[data-offen="nein"] .zt-pille__punkt {
|
||||
background: var(--zt-zu-punkt);
|
||||
box-shadow: 0 0 0 6px var(--zt-zu-ring);
|
||||
}
|
||||
.zt-titel {
|
||||
margin-top: 30px;
|
||||
}
|
||||
.zt-karte {
|
||||
position: absolute;
|
||||
left: -56px;
|
||||
bottom: 36px;
|
||||
width: 300px;
|
||||
padding: 18px 22px 16px;
|
||||
border-radius: 22px;
|
||||
background: var(--bk-papier);
|
||||
box-shadow: 0 24px 50px -24px rgb(60 30 5 / 0.45);
|
||||
}
|
||||
.zt-karte .bk-dach {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.zt-zeile {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 7px 10px;
|
||||
margin: 0 -10px;
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
color: var(--bk-tinte-leise);
|
||||
}
|
||||
.zt-zeile.is-heute {
|
||||
background: var(--bk-sand);
|
||||
color: var(--bk-tinte);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Handy (402 x 874): Statusleiste oben 59 frei, Leiste darunter ─────────────────────────────────── */
|
||||
|
||||
.zt-seite[data-variante="handy"] {
|
||||
--zt-leiste-h: 52px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-leiste {
|
||||
top: 59px;
|
||||
height: 52px;
|
||||
gap: 10px;
|
||||
font-size: 17px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-leiste__punkt {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
box-shadow: 0 0 0 4px rgb(242 181 68 / 0.28);
|
||||
}
|
||||
/* die oberen 59 px überdeckt die Statusleiste des iPhones (heller Text): ein dunkler Streifen macht Uhrzeit und Symbole lesbar */
|
||||
.zt-seite[data-variante="handy"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 59px;
|
||||
z-index: 4;
|
||||
background: var(--bk-tinte);
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-inhalt {
|
||||
top: 59px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-kopf {
|
||||
height: 64px;
|
||||
padding: 0 20px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .bk-logo {
|
||||
gap: 10px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .bk-logo__zeichen {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .bk-logo__name {
|
||||
font-size: 27px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .bk-logo__zusatz {
|
||||
margin-top: 3px;
|
||||
font-size: 9.5px;
|
||||
}
|
||||
.zt-burger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
width: 26px;
|
||||
}
|
||||
.zt-burger i {
|
||||
display: block;
|
||||
height: 2.5px;
|
||||
border-radius: 2px;
|
||||
background: var(--bk-tinte);
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-held {
|
||||
display: block;
|
||||
height: auto;
|
||||
padding: 22px 20px 0;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-pille {
|
||||
gap: 11px;
|
||||
max-width: 100%;
|
||||
padding: 9px 18px 9px 15px;
|
||||
border-width: 1.5px;
|
||||
font-size: 17px;
|
||||
line-height: 1.25;
|
||||
white-space: normal;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-pille__punkt {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
box-shadow: 0 0 0 4px var(--zt-offen-ring);
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-pille[data-offen="nein"] .zt-pille__punkt {
|
||||
box-shadow: 0 0 0 4px var(--zt-zu-ring);
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-titel {
|
||||
margin: 22px 0 14px;
|
||||
font-size: 62px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .bk-held__lead {
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-karte {
|
||||
position: static;
|
||||
width: auto;
|
||||
margin-top: 26px;
|
||||
border: 1px solid var(--bk-linie);
|
||||
box-shadow: none;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-zeile {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ── Uhr-Regler unter dem MacBook (normaler Seitenmaßstab, Seitentoken, hell und dunkel) ───────────── */
|
||||
|
||||
.zr {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
padding: 10px 16px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-alt);
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
}
|
||||
.zr *,
|
||||
.zr *::before,
|
||||
.zr *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.zr__kopf {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.zr__titel {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.zr__uhr {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--fg);
|
||||
}
|
||||
.zr__regler {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
accent-color: var(--accent-text);
|
||||
touch-action: pan-y;
|
||||
cursor: pointer;
|
||||
}
|
||||
.zr__regler:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.zr__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 0;
|
||||
min-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.zr__punkt {
|
||||
flex: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #3d9a4a;
|
||||
}
|
||||
.zr__status[data-offen="nein"] .zr__punkt {
|
||||
background: #c4622d;
|
||||
}
|
||||
.zr--kompakt {
|
||||
max-width: 320px;
|
||||
padding: 10px 14px 12px;
|
||||
}
|
||||
|
||||
/* ── Bewegung (nur transform und opacity, nur ohne reduzierte Bewegung) ────────────────────────────── */
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.zt-leiste {
|
||||
transition:
|
||||
transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
opacity 400ms ease;
|
||||
}
|
||||
.zt-inhalt {
|
||||
transition: transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.zt-pille__text {
|
||||
animation: zt-wechsel 420ms ease-out both;
|
||||
}
|
||||
}
|
||||
@keyframes zt-wechsel {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,7 @@
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
/* sehr niedrige Fenster: nur die aktive Funktion zeigt ihre Unterschrift (acht Stationen brauchen sonst mehr Platz) */
|
||||
/* sehr niedrige Fenster: nur die aktive Funktion zeigt ihre Unterschrift (neun Stationen brauchen sonst mehr Platz) */
|
||||
@media (max-height: 720px) {
|
||||
/* Oberkante bleibt stehen, wenn die Unterschriften wechseln (sonst würde die zentrierte Liste springen) */
|
||||
.kp-liste {
|
||||
@@ -368,16 +368,22 @@
|
||||
display: grid;
|
||||
}
|
||||
.kp-diagnose,
|
||||
.kp-regler,
|
||||
.kp-hinweis {
|
||||
grid-area: 1 / 1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.kp-diagnose[data-an="ja"],
|
||||
.kp-regler[data-an="ja"],
|
||||
.kp-hinweis[data-an="ja"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* Der Regler (Öffnungszeiten live) ist nie höher als die Diagnose, damit die Zelle beim Wechsel nicht springt */
|
||||
.kp-regler {
|
||||
align-self: start;
|
||||
}
|
||||
/* Hinweiskarte im Stil der Diagnose (gleiche Linie, gleiches Padding) */
|
||||
.kp-hinweis {
|
||||
align-self: start;
|
||||
@@ -408,6 +414,7 @@
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.kp-diagnose,
|
||||
.kp-regler,
|
||||
.kp-hinweis,
|
||||
.kp-beispiel {
|
||||
transition: opacity var(--dur-enter) var(--ease);
|
||||
@@ -539,7 +546,8 @@
|
||||
color: var(--fg-muted);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.kp-karte__diagnose {
|
||||
.kp-karte__diagnose,
|
||||
.kp-karte__regler {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin-top: 1rem;
|
||||
|
||||
@@ -176,14 +176,14 @@ describe("Quelltext", () => {
|
||||
});
|
||||
|
||||
it("lädt nichts von fremden Servern", () => {
|
||||
for (const datei of ["chat/ChatEbene.tsx", "chat/ChatSeite.tsx", "chat/ChatHandy.tsx", "chat/chat.css", "baeckerei/BaeckereiSeite.tsx", "baeckerei/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(/googleapis|gstatic|unsplash/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("bewegt nur transform und opacity und nur ohne reduzierte Bewegung", () => {
|
||||
for (const datei of ["chat/chat.css", "baeckerei/baeckerei.css"]) {
|
||||
for (const datei of ["chat/chat.css", "baeckerei/baeckerei.css", "zeiten/zeiten.css"]) {
|
||||
const css = quelle(datei).replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
for (const treffer of css.matchAll(/@keyframes[^{]+\{([\s\S]*?)\n\}/g)) {
|
||||
const eigenschaften = [...treffer[1].matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1]);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, renderHook, act, cleanup } from "@testing-library/react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, renderHook, act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { Logo, Stempel, Pfeil } from "@/components/home/demos/baeckerei/teile";
|
||||
import ZeitenSeite from "@/components/home/demos/zeiten/ZeitenSeite";
|
||||
import ZeitenHandy from "@/components/home/demos/zeiten/ZeitenHandy";
|
||||
import ZeitenRegler from "@/components/home/demos/zeiten/ZeitenRegler";
|
||||
import { ZeitenSteuerung } from "@/components/home/demos/steuerung";
|
||||
import Kundenportal from "@/components/home/Kundenportal";
|
||||
import {
|
||||
useZeitenAblauf,
|
||||
statusFuer,
|
||||
@@ -15,6 +20,7 @@ import {
|
||||
HINWEIS_ANLEGEN_PUNKT,
|
||||
ZEITEN_START,
|
||||
ZEITEN_RUNDE_MS,
|
||||
type ZeitenZustand,
|
||||
} from "@/components/home/demos/zeiten/ablauf";
|
||||
|
||||
// Spezifikation 2026-10-04-oeffnungszeiten-live, Abschnitt 10 (Relaunch-Station): Bäckerei Sonnenschein, Montag 5. Oktober 2026
|
||||
@@ -377,3 +383,510 @@ describe("Quelltext", () => {
|
||||
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*\{/);
|
||||
});
|
||||
});
|
||||
@@ -5,14 +5,14 @@ import { join } from "node:path";
|
||||
import Kundenportal from "@/components/home/Kundenportal";
|
||||
import GeraeteRahmen, { DesktopFenster, HandyRahmen } from "@/components/GeraeteRahmen";
|
||||
|
||||
// Spezifikation 7.2 (Kundenportal) und 9.5 (Bilder); Funktions-Demos: 2026-10-04-funktionen-demos (Stationen 5 bis 7);
|
||||
// Spezifikation 7.2 (Kundenportal) und 9.5 (Bilder); Funktions-Demos: 2026-10-04-funktionen-demos (Stationen 5 bis 8: Terminplaner, Website-Analyse, News-Pinnwand, Öffnungszeiten live);
|
||||
// Live-Vorschau, Chat-Demo und weiches Auf-/Zuklappen: 2026-10-04-vorschau-chat
|
||||
|
||||
const WURZEL = `${process.cwd()}/`;
|
||||
const BILDORDNER = join(WURZEL, "public/relaunch/portal");
|
||||
|
||||
const TITEL = ["Live-Vorschau", "Fortschritt", "Chat", "Rechnungen", "Terminplaner", "Website-Analyse", "News-Pinnwand", "Als App"];
|
||||
/** Ein Satz je Portal-Station; die drei Funktionen zeigen nur ihren Titel (Wunsch des Inhabers 2026-10-04). */
|
||||
const TITEL = ["Live-Vorschau", "Fortschritt", "Chat", "Rechnungen", "Terminplaner", "Website-Analyse", "News-Pinnwand", "Öffnungszeiten live", "Als App"];
|
||||
/** Ein Satz je Portal-Station; die vier Funktionen zeigen nur ihren Titel (Wunsch des Inhabers 2026-10-04). */
|
||||
const UNTERSCHRIFTEN: (string | null)[] = [
|
||||
"Ihre Website, noch während wir daran bauen.",
|
||||
"Was wir zuletzt erledigt haben – in einfachen Sätzen.",
|
||||
@@ -21,6 +21,7 @@ const UNTERSCHRIFTEN: (string | null)[] = [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"Aufs Handy installieren, fertig.",
|
||||
];
|
||||
const SCHRITTE = [
|
||||
@@ -98,12 +99,12 @@ describe("Kundenportal: Text und Reihenfolge", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Handy: die acht Funktionen stehen in der richtigen Reihenfolge mit ihren Unterschriften", () => {
|
||||
it("Handy: die neun Funktionen stehen in der richtigen Reihenfolge mit ihren Unterschriften", () => {
|
||||
zeigeHandy();
|
||||
expect(screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent)).toEqual(TITEL);
|
||||
for (const text of UNTERSCHRIFTEN) if (text) expect(screen.getByText(text)).toBeInTheDocument();
|
||||
// die drei Funktions-Karten: nur der Titel, kein Satz und keine Stichpunkte
|
||||
for (const titel of ["Terminplaner", "Website-Analyse", "News-Pinnwand"]) {
|
||||
// die vier Funktions-Karten: nur der Titel, kein Satz und keine Stichpunkte
|
||||
for (const titel of ["Terminplaner", "Website-Analyse", "News-Pinnwand", "Öffnungszeiten live"]) {
|
||||
const karte = screen.getByRole("heading", { level: 3, name: titel }).closest(".kp-karte")!;
|
||||
expect(karte.querySelector(".kp-karte__text, ul")).toBeNull();
|
||||
}
|
||||
@@ -121,30 +122,37 @@ describe("Kundenportal: Text und Reihenfolge", () => {
|
||||
oben.forEach((titel, i) => expect(eintraege[i]).toHaveTextContent(new RegExp(`^${titel}`)));
|
||||
// Portal-Stationen behalten ihren Satz
|
||||
[0, 1, 2, 3].forEach((i) => expect(eintraege[i]).toHaveTextContent(UNTERSCHRIFTEN[i] as string));
|
||||
expect(eintraege[5]).toHaveTextContent(UNTERSCHRIFTEN[7] as string);
|
||||
// Überpunkt: zugeklappt, die drei Unterpunkte (nur Titel, keine Stichpunkte) sind versteckt
|
||||
expect(eintraege[5]).toHaveTextContent(UNTERSCHRIFTEN[8] as string);
|
||||
// Überpunkt: zugeklappt, die vier Unterpunkte (nur Titel, keine Stichpunkte) sind versteckt
|
||||
const gruppe = eintraege[4];
|
||||
expect(gruppe).toHaveAttribute("aria-expanded", "false");
|
||||
const unterliste = liste.querySelector<HTMLElement>("#kp-unterliste")!;
|
||||
expect(unterliste).toHaveAttribute("aria-label", "Funktionen");
|
||||
expect(unterliste).not.toBeVisible();
|
||||
const unter = within(unterliste).getAllByRole("button", { hidden: true });
|
||||
expect(unter.map((b) => b.textContent)).toEqual(["TerminplanerBald verfügbar", "Website-Analyse", "News-Pinnwand"]);
|
||||
expect(unter.map((b) => b.textContent)).toEqual([
|
||||
"TerminplanerBald verfügbar",
|
||||
"Website-Analyse",
|
||||
"News-Pinnwand",
|
||||
"Öffnungszeiten liveBald verfügbar",
|
||||
]);
|
||||
expect(liste).not.toHaveTextContent("Heatmap: wohin Besucher klicken");
|
||||
});
|
||||
|
||||
it("der Terminplaner trägt „Bald verfügbar“ (noch nicht buchbar), die anderen Stationen nicht", () => {
|
||||
it("Terminplaner und Öffnungszeiten live tragen „Bald verfügbar“ (noch nicht buchbar), die anderen Stationen nicht", () => {
|
||||
zeigeDesktop();
|
||||
const liste = screen.getByRole("list", { name: "Funktionen des Kundenportals" });
|
||||
const marken = liste.querySelectorAll(".kp-bald");
|
||||
expect(marken).toHaveLength(1);
|
||||
expect(marken).toHaveLength(2);
|
||||
expect(marken[0].closest("button")).toHaveTextContent(/^Terminplaner/);
|
||||
expect(marken[1].closest("button")).toHaveTextContent(/^Öffnungszeiten live/);
|
||||
cleanup();
|
||||
|
||||
zeigeHandy();
|
||||
const karten = screen.getAllByText("Bald verfügbar");
|
||||
expect(karten).toHaveLength(1);
|
||||
expect(karten).toHaveLength(2);
|
||||
expect(karten[0].closest(".kp-karte")).toContainElement(screen.getByRole("heading", { level: 3, name: "Terminplaner" }));
|
||||
expect(karten[1].closest(".kp-karte")).toContainElement(screen.getByRole("heading", { level: 3, name: "Öffnungszeiten live" }));
|
||||
});
|
||||
|
||||
it("Tablet quer (Bühne ohne Maus): der Bedienhinweis der Website-Analyse sagt „Tippen Sie“ statt „Zeigen Sie mit der Maus“", () => {
|
||||
@@ -157,7 +165,7 @@ describe("Kundenportal: Text und Reihenfolge", () => {
|
||||
it.each([
|
||||
["Handy", zeigeHandy],
|
||||
["Desktop", zeigeDesktop],
|
||||
])("%s: bewirbt weder Stundenkonto noch Verträge (Terminplaner, Analyse und Pinnwand sind die Demos)", (_name, zeige) => {
|
||||
])("%s: bewirbt weder Stundenkonto noch Verträge (Terminplaner, Analyse, Pinnwand und Öffnungszeiten live sind die Demos)", (_name, zeige) => {
|
||||
const { container } = zeige();
|
||||
const alts = Array.from(container.querySelectorAll("img")).map((img) => img.getAttribute("alt") ?? "");
|
||||
const gesamt = `${container.textContent} ${alts.join(" ")}`;
|
||||
@@ -186,7 +194,7 @@ describe("Kundenportal: Bilder (Spezifikation 9.5)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("Handy: fünf Bilder, nur Handy-Aufnahmen (die drei Demo-Karten zeigen Websites); das lange Bild behält sein hohes Seitenverhältnis", () => {
|
||||
it("Handy: fünf Bilder, nur Handy-Aufnahmen (die vier Demo-Karten zeigen Websites); das lange Bild behält sein hohes Seitenverhältnis", () => {
|
||||
const { container } = zeigeHandy();
|
||||
const bilder = Array.from(container.querySelectorAll("img"));
|
||||
expect(bilder).toHaveLength(5);
|
||||
@@ -276,17 +284,17 @@ describe("Kundenportal: Schrittzeile", () => {
|
||||
});
|
||||
|
||||
describe("Kundenportal: Wisch-Reihen und Bedienung", () => {
|
||||
it("Handy: beide Wisch-Reihen sind per Tastatur erreichbar und beschriftet, höchstens 8 Karten", () => {
|
||||
it("Handy: beide Wisch-Reihen sind per Tastatur erreichbar und beschriftet, höchstens 9 Karten", () => {
|
||||
zeigeHandy();
|
||||
const reihen = screen.getAllByRole("list").filter((l) => l.classList.contains("wk-wisch__reihe"));
|
||||
expect(reihen).toHaveLength(2);
|
||||
for (const reihe of reihen) {
|
||||
expect(reihe).toHaveAttribute("tabindex", "0");
|
||||
expect(reihe.getAttribute("aria-label")).toMatch(/wischen/);
|
||||
expect(reihe.children.length).toBeLessThanOrEqual(8);
|
||||
expect(reihe.children.length).toBeLessThanOrEqual(9);
|
||||
}
|
||||
expect(reihen[0].children).toHaveLength(8);
|
||||
expect(reihen[0].getAttribute("aria-label")).toMatch(/^Acht Funktionen/);
|
||||
expect(reihen[0].children).toHaveLength(9);
|
||||
expect(reihen[0].getAttribute("aria-label")).toMatch(/^Neun Funktionen/);
|
||||
});
|
||||
|
||||
it("Desktop: keine Wisch-Reihe, dafür die Bühne", () => {
|
||||
@@ -329,7 +337,7 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
const meldeMarke = (index: number) => {
|
||||
const spur = beobachter.find((b) => b.optionen?.rootMargin === "-50% 0px -50% 0px");
|
||||
expect(spur, "Beobachter auf der Spur aus Markern").toBeDefined();
|
||||
expect(spur!.ziele).toHaveLength(8);
|
||||
expect(spur!.ziele).toHaveLength(9);
|
||||
act(() => {
|
||||
spur!.rueckruf([{ isIntersecting: true, target: spur!.ziele[index] } as unknown as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
});
|
||||
@@ -341,7 +349,7 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
.find((b) => b.getAttribute("aria-current") === "step")
|
||||
?.textContent;
|
||||
|
||||
it("der Überpunkt „Funktionen“ klappt auf, sobald eine der drei Funktionen erreicht ist, und danach wieder zu", () => {
|
||||
it("der Überpunkt „Funktionen“ klappt auf, sobald eine der vier Funktionen erreicht ist, und danach wieder zu", () => {
|
||||
beobachterAttrappe();
|
||||
zeigeDesktop();
|
||||
const gruppe = screen.getByRole("button", { name: /^Funktionen/ });
|
||||
@@ -353,11 +361,14 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
meldeMarke(5);
|
||||
expect(aktiverEintrag()).toBe("Website-Analyse");
|
||||
meldeMarke(7);
|
||||
expect(gruppe).toHaveAttribute("aria-expanded", "true");
|
||||
expect(aktiverEintrag()).toBe("Öffnungszeiten liveBald verfügbar");
|
||||
meldeMarke(8);
|
||||
expect(gruppe).toHaveAttribute("aria-expanded", "false");
|
||||
expect(aktiverEintrag()).toMatch(/^Als App/);
|
||||
});
|
||||
|
||||
it("die aktive Funktion ist hervorgehoben, die Bilder folgen, bei Schritt 8 blendet das Fenster aus", () => {
|
||||
it("die aktive Funktion ist hervorgehoben, die Bilder folgen, bei Schritt 9 blendet das Fenster aus", () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
expect(aktiverEintrag()).toMatch(/^Live-Vorschau/);
|
||||
@@ -373,39 +384,40 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
expect(lagen.filter((l) => !l.classList.contains("is-aktiv")).every((l) => l.getAttribute("aria-hidden") === "true")).toBe(true);
|
||||
expect(container.querySelector(".kp-buehne")).toHaveAttribute("data-app", "nein");
|
||||
|
||||
meldeMarke(7);
|
||||
meldeMarke(8);
|
||||
expect(aktiverEintrag()).toMatch(/^Als App/);
|
||||
expect(container.querySelector(".kp-buehne")).toHaveAttribute("data-app", "ja");
|
||||
expect(container.querySelector(".kp-fenster .kp-ebenen")).toHaveAttribute("aria-hidden", "true");
|
||||
// der lange Screenshot ist jetzt eingehängt und aktiv
|
||||
const lang = container.querySelector(".kp-handy .kp-lage.is-aktiv .kp-lang img");
|
||||
expect(lang?.getAttribute("src")).toContain("phone-2-projekte-lang");
|
||||
// alle früheren Bilder sind inzwischen eingehängt: zwei Desktop-Aufnahmen (Fortschritt, Rechnungen) und acht Handy-Aufnahmen
|
||||
// alle früheren Bilder sind inzwischen eingehängt: zwei Desktop-Aufnahmen (Fortschritt, Rechnungen) und neun Handy-Aufnahmen
|
||||
// (die Live-Stationen zeigen im MacBook Website bzw. Chat, deren Bilder liegen tiefer in der Ebene)
|
||||
expect(container.querySelectorAll(".kp-lage > img, .kp-lage > .kp-lang > img").length).toBe(2 + 8);
|
||||
expect(container.querySelectorAll(".kp-lage > img, .kp-lage > .kp-lang > img").length).toBe(2 + 9);
|
||||
});
|
||||
|
||||
it("Demo-Stationen: die drei Websites werden nachgeladen, sobald die Bühne sie fast erreicht, und aktiv ist nur eine", async () => {
|
||||
it("Demo-Stationen: die vier Websites werden nachgeladen, sobald die Bühne sie fast erreicht, und aktiv ist nur eine", async () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
// zu Beginn ist noch keine Demo eingehängt
|
||||
expect(container.querySelector(".kp-fenster .ts, .kp-fenster .hm, .kp-fenster .pw-seite")).toBeNull();
|
||||
expect(container.querySelector(".kp-fenster .ts, .kp-fenster .hm, .kp-fenster .pw-seite, .kp-fenster .zt-seite")).toBeNull();
|
||||
|
||||
meldeMarke(3); // Rechnungen: der nächste Schritt (Terminplaner) wird schon eingehängt
|
||||
await waitFor(() => expect(container.querySelector(".kp-fenster .ts")).not.toBeNull());
|
||||
expect(container.querySelector(".kp-fenster .hm")).toBeNull();
|
||||
|
||||
meldeMarke(6);
|
||||
meldeMarke(6); // Pinnwand: der nächste Schritt (Öffnungszeiten live) wird schon eingehängt
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".kp-fenster .hm")).not.toBeNull();
|
||||
expect(container.querySelector(".kp-fenster .pw-seite")).not.toBeNull();
|
||||
expect(container.querySelector(".kp-fenster .zt-seite")).not.toBeNull();
|
||||
});
|
||||
// jede Website liegt in einer eigenen Ebene: nur die aktive ist sichtbar, die anderen für Screenreader und Maus aus
|
||||
const lagen = Array.from(container.querySelectorAll(".kp-fenster .kp-lage"));
|
||||
expect(lagen.filter((l) => l.classList.contains("is-aktiv"))).toHaveLength(1);
|
||||
expect(container.querySelector(".kp-fenster .kp-lage.is-aktiv .pw-seite")).not.toBeNull();
|
||||
// Wurzel jeder Website: aria-hidden, nichts Fokussierbares, data-phase für die Prüfung
|
||||
for (const wurzel of Array.from(container.querySelectorAll(".kp-fenster .ts, .kp-fenster .hm, .kp-fenster .pw-seite"))) {
|
||||
for (const wurzel of Array.from(container.querySelectorAll(".kp-fenster .ts, .kp-fenster .hm, .kp-fenster .pw-seite, .kp-fenster .zt-seite"))) {
|
||||
expect(wurzel).toHaveAttribute("aria-hidden", "true");
|
||||
expect(wurzel).toHaveAttribute("data-phase");
|
||||
expect(wurzel.querySelectorAll("a, button, input, select, textarea, [tabindex]")).toHaveLength(0);
|
||||
@@ -429,6 +441,8 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
expect(adresse()).toBe("boulderhalle-ueberhang.example");
|
||||
meldeMarke(6);
|
||||
expect(adresse()).toBe("musterbach.example");
|
||||
meldeMarke(7);
|
||||
expect(adresse()).toBe("baeckerei-sonnenschein.example");
|
||||
meldeMarke(2);
|
||||
expect(adresse()).toBe("project.webklar.com");
|
||||
await waitFor(() => expect(container.querySelector(".kp-fenster .ts")).not.toBeNull());
|
||||
@@ -458,15 +472,16 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
// mit Maus (diese Bühne meldet eine feine Zeigereingabe): Hinweis zum Zeigen
|
||||
"Erfundenes Beispiel · Zeigen Sie mit der Maus auf die Seite.",
|
||||
"Erfundenes Beispiel · musterbach.example",
|
||||
"Erfundenes Beispiel · baeckerei-sonnenschein.example",
|
||||
]);
|
||||
expect(hinweise.map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "ja", "nein"]);
|
||||
expect(hinweise.map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "ja", "nein", "nein"]);
|
||||
|
||||
meldeMarke(6);
|
||||
expect(diagnose()).toHaveAttribute("data-an", "nein");
|
||||
expect(diagnose()).toHaveAttribute("inert");
|
||||
});
|
||||
|
||||
it("Live-Vorschau, Chat, Terminplaner und Pinnwand tragen unter dem MacBook eine Hinweiskarte; nur die der aktiven Station ist sichtbar", () => {
|
||||
it("Live-Vorschau, Chat, Terminplaner und Pinnwand tragen unter dem MacBook eine Hinweiskarte (Öffnungszeiten live den Regler); nur die der aktiven Station ist sichtbar", () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
const hinweise = () => Array.from(container.querySelectorAll(".kp-hinweis")) as HTMLElement[];
|
||||
@@ -490,6 +505,9 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "nein"]);
|
||||
meldeMarke(6);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "ja"]);
|
||||
// Öffnungszeiten live hat statt einer Hinweiskarte den Uhr-Regler
|
||||
meldeMarke(7);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "nein"]);
|
||||
});
|
||||
|
||||
it("Live-Vorschau: im MacBook läuft die Beispiel-Website der Bäckerei (nachgeladen, aria-hidden, nichts Fokussierbares)", async () => {
|
||||
@@ -529,9 +547,9 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
const gruppe = screen.getByRole("button", { name: /^Funktionen/ });
|
||||
const liste = screen.getByRole("list", { name: "Funktionen des Kundenportals" });
|
||||
const unterliste = liste.querySelector<HTMLElement>("#kp-unterliste")!;
|
||||
meldeMarke(6);
|
||||
expect(gruppe).toHaveAttribute("aria-expanded", "true");
|
||||
meldeMarke(7);
|
||||
expect(gruppe).toHaveAttribute("aria-expanded", "true");
|
||||
meldeMarke(8);
|
||||
// sofort zu für Screenreader, sichtbar faltet sich die Unterliste noch ein
|
||||
expect(gruppe).toHaveAttribute("aria-expanded", "false");
|
||||
expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "schliesst");
|
||||
@@ -541,7 +559,7 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "zu");
|
||||
// geht sie während des Einfaltens wieder auf, bleibt sie offen
|
||||
meldeMarke(5);
|
||||
meldeMarke(7);
|
||||
meldeMarke(8);
|
||||
meldeMarke(5);
|
||||
expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "offen");
|
||||
await new Promise((r) => setTimeout(r, 320));
|
||||
@@ -574,15 +592,15 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
expect(lage.querySelector(".kp-platzhalter, .ts")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Handy-Karten: die drei Websites laden erst, wenn die Karte in Sicht kommt; Tippen auf die Heatmap wählt eine Zone", async () => {
|
||||
it("Handy-Karten: die vier Websites laden erst, wenn die Karte in Sicht kommt; Tippen auf die Heatmap wählt eine Zone", async () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeHandy();
|
||||
// drei Funktions-Karten (Websites) und die Chat-Karte
|
||||
expect(container.querySelectorAll(".kp-karte--demo")).toHaveLength(4);
|
||||
// vier Funktions-Karten (Websites) und die Chat-Karte
|
||||
expect(container.querySelectorAll(".kp-karte--demo")).toHaveLength(5);
|
||||
const karten = Array.from(container.querySelectorAll(".kp-karte--demo[data-demo]"));
|
||||
expect(karten).toHaveLength(3);
|
||||
expect(karten).toHaveLength(4);
|
||||
// noch nichts gesehen: Rahmen leer, nichts geladen
|
||||
expect(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite)")).toHaveLength(0);
|
||||
expect(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite, .zt-seite)")).toHaveLength(0);
|
||||
// jede Karte meldet sich über ihre Beobachter als sichtbar
|
||||
act(() => {
|
||||
for (const b of beobachter) {
|
||||
@@ -592,8 +610,8 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
b.rueckruf(ziele.map((target) => ({ isIntersecting: true, target }) as unknown as IntersectionObserverEntry), {} as IntersectionObserver);
|
||||
}
|
||||
});
|
||||
await waitFor(() => expect(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite)[data-variante='handy']")).toHaveLength(3));
|
||||
for (const wurzel of Array.from(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite)[data-variante='handy']")))
|
||||
await waitFor(() => expect(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite, .zt-seite)[data-variante='handy']")).toHaveLength(4));
|
||||
for (const wurzel of Array.from(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite, .zt-seite)[data-variante='handy']")))
|
||||
expect(wurzel).toHaveAttribute("aria-hidden", "true");
|
||||
// Heatmap-Karte: kompakte Diagnose darunter, Tippen auf eine Zone wählt sie
|
||||
const heat = container.querySelector(".kp-karte--demo[data-demo='heatmap']") as HTMLElement;
|
||||
|
||||
Reference in new issue
Block a user