Kundenportal-Bühne: Terminplaner, Heatmap und Pinnwand als Live-Demos statt „Funktionen“
Die Station „Funktionen“ ist durch drei Stationen ersetzt (Gruppe „Funktionen zum Dazubuchen“). Auf dem MacBook läuft je eine erfundene Kunden-Website als Live-Komponente, auf dem iPhone die Portal-Ansicht der Funktion (echter Portal-Code, Fake-Daten): - Terminplaner (Salon Schnittstelle): Buchung im Widget, Bestätigung per Link, Eintrag im Kalender-Abo, Mitteilung „Neue Buchung …“ auf dem Handy - Website-Analyse (Boulderhalle Überhang): simulierte Klicks bauen die Heatmap auf (Algorithmus wie analytics/client/h.js); Maus über der Seite zeigt Zone + Klickzahl, darunter die Diagnose mit Rangliste und Satz (Beispieldaten) - News-Pinnwand (Ortsgemeinde Musterbach): „Aufhängen“ im Portal, Zettel fällt auf die Pinnwand Demos laden als eigene Chunks, laufen nur bei aktiver, sichtbarer Station und ohne reduzierte Bewegung. Unter 900 px zeigen die Karten die Website in der Handy-Fassung. Spezifikation: docs/superpowers/specs/2026-10-04-funktionen-demos.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c9ad9ba2b7
commit
72275db9fa
39 files changed
+6567
-75
No files matched your search
@@ -1,6 +1,10 @@
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { lazy, Suspense, useEffect, useRef, useState, useSyncExternalStore, version, type ComponentType, type ReactNode, type RefObject } from "react";
|
||||
import WischReihe from "@/components/WischReihe";
|
||||
import { DesktopFenster, HandyRahmen, RahmenBild, type RahmenBildDaten } from "@/components/GeraeteRahmen";
|
||||
import { useDemoLauf, useFeinerZeiger, useImBild } from "./demos/gemeinsam";
|
||||
import type { TerminZustand } from "./demos/termin/ablauf";
|
||||
import type { HeatmapZustand } from "./demos/heatmap/ablauf";
|
||||
import type { PinnwandZustand } from "./demos/pinnwand/ablauf";
|
||||
import "./kundenportal.css";
|
||||
|
||||
/**
|
||||
@@ -8,6 +12,9 @@ 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
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const EINLEITUNG =
|
||||
@@ -43,17 +50,32 @@ const handyBild = (name: string, alt: string, sizes: string, hoehe = 1696): Rahm
|
||||
alt,
|
||||
});
|
||||
|
||||
type DemoArt = "termin" | "heatmap" | "pinnwand";
|
||||
|
||||
type Funktion = {
|
||||
id: string;
|
||||
titel: string;
|
||||
unterschrift: string;
|
||||
/** Desktop-Bild; Schritt 6 hat keines, dort blendet das Fenster aus. */
|
||||
/** Abweichende Unterschrift in der Wisch-Reihe (dort gibt es keine Maus). */
|
||||
unterschriftHandy?: string;
|
||||
/** Desktop-Bild; Demo-Stationen zeigen stattdessen die Website, "Als App" hat keines (dort blendet das Fenster aus). */
|
||||
desktop?: { name: string; alt: string };
|
||||
handy: { name: string; alt: string };
|
||||
/** Der lange Screenshot, der sich im Handy-Rahmen nach oben schiebt. */
|
||||
lang?: boolean;
|
||||
/** Live-Demo statt Desktop-Bild: welche erfundene Kunden-Website im MacBook läuft. */
|
||||
demo?: DemoArt;
|
||||
/** Adresse in der Safari-Leiste (reservierte Endung .example, es ist kein echter Betrieb gemeint). */
|
||||
adresse?: string;
|
||||
/** Beschreibung der Beispiel-Website für Screenreader (die Website selbst ist aria-hidden). */
|
||||
beschreibung?: string;
|
||||
/** Hinweis unter dem MacBook (Terminplaner und Pinnwand; bei der Website-Analyse steht dort die Diagnose). */
|
||||
hinweis?: string;
|
||||
};
|
||||
|
||||
/** 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 };
|
||||
|
||||
// Reihenfolge und Texte laut Spezifikation 7.2. Platzhalter des Portals ("kommt bald") werden nicht beworben,
|
||||
// weitere Ansichten nur nach Freigabe des Inhabers (O9 = nein).
|
||||
const FUNKTIONEN: Funktion[] = [
|
||||
@@ -92,16 +114,45 @@ const FUNKTIONEN: Funktion[] = [
|
||||
handy: { name: "phone-5-rechnungen", alt: "Kundenportal auf dem Handy: Rechnungen zum Ansehen, Bezahlen und Herunterladen" },
|
||||
},
|
||||
{
|
||||
id: "funktionen",
|
||||
titel: "Funktionen",
|
||||
unterschrift: "Bausteine zum Dazubuchen, zum Beispiel eine News-Pinnwand für Ihre Website.",
|
||||
desktop: {
|
||||
name: "desktop-7-news-pinnwand",
|
||||
alt: "Kundenportal: News-Pinnwand mit Vorschau, wie die Meldung auf der Website aussieht",
|
||||
},
|
||||
id: "terminplaner",
|
||||
titel: "Terminplaner",
|
||||
unterschrift: "Gäste buchen auf Ihrer Website – die Termine sehen Sie im Portal und auf Wunsch im Google Kalender.",
|
||||
demo: "termin",
|
||||
adresse: "salon-schnittstelle.example",
|
||||
beschreibung:
|
||||
"Beispiel-Website eines Friseursalons mit Buchungsformular und einem Kalender, in dem der neue Termin erscheint",
|
||||
hinweis:
|
||||
"Kalender-Abo: Den Link einmal in Google Kalender, Apple Kalender oder Outlook einfügen. Neue Termine erscheinen dort mit etwas Verzögerung.",
|
||||
handy: {
|
||||
name: "phone-8-news-vorschau",
|
||||
alt: "Kundenportal auf dem Handy: Vorschau der News-Pinnwand für die Website",
|
||||
name: "phone-9-termine",
|
||||
alt: "Kundenportal von WEBklar auf dem Handy: Der Terminplaner zeigt die Termine des Donnerstags, darunter eine bestätigte Buchung, eine neue Anfrage, die Sie annehmen, ablehnen oder verschieben können, und einen weiteren bestätigten Termin.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "website-analyse",
|
||||
titel: "Website-Analyse",
|
||||
unterschrift: "Sehen Sie, wohin Ihre Besucher klicken. Zeigen Sie mit der Maus auf die Seite.",
|
||||
unterschriftHandy: "Sehen Sie, wohin Ihre Besucher klicken. Tippen Sie auf eine Stelle der Seite.",
|
||||
demo: "heatmap",
|
||||
adresse: "boulderhalle-ueberhang.example",
|
||||
beschreibung:
|
||||
"Beispiel-Website einer Boulderhalle, darüber eine Heatmap: Je häufiger eine Stelle angeklickt wird, desto wärmer ihre Farbe",
|
||||
handy: {
|
||||
name: "phone-10-analyse",
|
||||
alt: "Kundenportal von WEBklar auf dem Handy: Die Website-Analyse zeigt für die letzten 30 Tage Seitenaufrufe, Besucher, Kontakt-Klicks und die Zustimmungsquote, darunter ein Diagramm mit den Seitenaufrufen pro Tag.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "news-pinnwand",
|
||||
titel: "News-Pinnwand",
|
||||
unterschrift: "Neuigkeiten mit Text und Foto, vom Handy direkt auf Ihre Website.",
|
||||
demo: "pinnwand",
|
||||
adresse: "musterbach.example",
|
||||
beschreibung: "Beispiel-Website einer Ortsgemeinde mit einer Pinnwand, an der ein neuer Zettel aufgehängt wird",
|
||||
hinweis: "Neuer Zettel: Er erscheint nach dem Aufhängen auf der Website.",
|
||||
handy: {
|
||||
name: "phone-11-news-musterbach",
|
||||
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.",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -117,6 +168,51 @@ const FUNKTIONEN: Funktion[] = [
|
||||
];
|
||||
|
||||
const LETZTER = FUNKTIONEN.length - 1;
|
||||
/** Stationen vor "Als App": normale Schritte und längere Demo-Schritte (bestimmen die Scrollstrecke der Hülle). */
|
||||
const ANZAHL_DEMO = FUNKTIONEN.filter((f) => f.demo).length;
|
||||
const ANZAHL_NORMAL = LETZTER - ANZAHL_DEMO;
|
||||
const GRUPPE_TITEL = "Funktionen zum Dazubuchen";
|
||||
|
||||
// ── Demos (eigener Chunk, erst geladen, wenn die Bühne die Station fast erreicht) ──────────────────────
|
||||
|
||||
/**
|
||||
* React.lazy mit `vorladen`: Der Chunk wird beim Einhängen der Station angestoßen, nicht erst, wenn die Komponente
|
||||
* zum ersten Mal gerendert wird. So laden Website, Handy-Ebene und Taktgeber gleichzeitig (kein Wasserfall).
|
||||
*/
|
||||
const lazyMitVorladen = <P,>(laden: () => Promise<{ default: ComponentType<P> }>) => {
|
||||
let versprechen: ReturnType<typeof laden> | undefined;
|
||||
const lade = () => (versprechen ??= laden());
|
||||
const Komponente = lazy(lade);
|
||||
return Object.assign(Komponente, { vorladen: () => void lade().catch(() => {}) });
|
||||
};
|
||||
|
||||
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 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"));
|
||||
|
||||
/** Chunks je Demo; die Website-Chunks (Seite, Diagnose) und der Taktgeber laden gleichzeitig. */
|
||||
const VORLADEN: Record<DemoArt, (() => void)[]> = {
|
||||
termin: [TerminSteuerung.vorladen, TerminSeite.vorladen, TerminHandy.vorladen],
|
||||
heatmap: [HeatmapSteuerung.vorladen, HeatmapSeite.vorladen, HeatmapDiagnose.vorladen],
|
||||
pinnwand: [PinnwandSteuerung.vorladen, PinnwandSeite.vorladen, PinnwandHandy.vorladen],
|
||||
};
|
||||
|
||||
/** Hintergrund der Beispiel-Websites, solange ihr Chunk lädt (statt einer schwarzen Fläche im MacBook). */
|
||||
const PLATZHALTER: Record<DemoArt, string> = { termin: "#f6f1ea", heatmap: "#141414", pinnwand: "#f7f2e6" };
|
||||
const Platzhalter = ({ art }: { art: DemoArt }) => (
|
||||
<div className="kp-platzhalter" style={{ background: PLATZHALTER[art] }} aria-hidden="true" />
|
||||
);
|
||||
|
||||
// inert: React 19 kennt es als boolesches Attribut, React 18 reicht nur den String durch ('' = gesetzt)
|
||||
const INERT = (parseInt(version, 10) >= 19 ? { inert: true } : { inert: "" }) as unknown as { inert?: boolean };
|
||||
|
||||
const beispielText = (f: Funktion) => `Erfundenes Beispiel · ${f.adresse}`;
|
||||
|
||||
const SCHRITTE = [
|
||||
{ nr: 1, text: "Kennenlernen" },
|
||||
@@ -159,6 +255,16 @@ const Buehne = () => {
|
||||
const buehne = useRef<HTMLDivElement>(null);
|
||||
const marker = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
// Demos: laeuft = Station aktiv, Bühne im Bild, Tab sichtbar, Bewegung erlaubt (Spezifikation 4).
|
||||
// Je Demo gibt es einen Taktgeber (eigener Chunk); sein Zustand geht an Website, Handy-Ebene und Diagnose.
|
||||
const imBild = useImBild(buehne, 0.35);
|
||||
const lauf = useDemoLauf(true, imBild);
|
||||
const fein = useFeinerZeiger();
|
||||
const aktivDemo = FUNKTIONEN[aktiv].demo;
|
||||
const [termin, setTermin] = useState<TerminZustand | null>(null);
|
||||
const [heat, setHeat] = useState<HeatmapZustand | null>(null);
|
||||
const [pinnwand, setPinnwand] = useState<PinnwandZustand | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof IntersectionObserver === "undefined") return;
|
||||
const beobachter = new IntersectionObserver(
|
||||
@@ -237,20 +343,55 @@ const Buehne = () => {
|
||||
|
||||
const app = aktiv === LETZTER;
|
||||
const eingehaengt = (i: number) => i <= hoechster + 1;
|
||||
const demoEingehaengt = (art: DemoArt) => FUNKTIONEN.some((f, i) => f.demo === art && eingehaengt(i));
|
||||
|
||||
// Sobald die Station eingehängt ist, laden alle Chunks der Demo gleichzeitig (Website, Handy-Ebene, Taktgeber)
|
||||
useEffect(() => {
|
||||
for (const art of Object.keys(VORLADEN) as DemoArt[]) {
|
||||
if (FUNKTIONEN.some((f, i) => f.demo === art && i <= hoechster + 1)) VORLADEN[art].forEach((vorladen) => vorladen());
|
||||
}
|
||||
}, [hoechster]);
|
||||
|
||||
return (
|
||||
<div className="kp-huelle" ref={huelle}>
|
||||
<div
|
||||
className="kp-huelle"
|
||||
ref={huelle}
|
||||
style={{ ["--kp-nn" as string]: ANZAHL_NORMAL, ["--kp-nd" as string]: ANZAHL_DEMO }}
|
||||
>
|
||||
{/* Taktgeber der Demos (rendern nichts): erst geladen, wenn die Bühne ihre Station fast erreicht */}
|
||||
<Suspense fallback={null}>
|
||||
{demoEingehaengt("termin") ? (
|
||||
<TerminSteuerung laeuft={lauf.laeuft && aktivDemo === "termin"} statisch={lauf.statisch} onZustand={setTermin} />
|
||||
) : null}
|
||||
{demoEingehaengt("heatmap") ? (
|
||||
<HeatmapSteuerung
|
||||
laeuft={lauf.laeuft && aktivDemo === "heatmap"}
|
||||
statisch={lauf.statisch}
|
||||
aktiv={aktivDemo === "heatmap"}
|
||||
onZustand={setHeat}
|
||||
/>
|
||||
) : null}
|
||||
{demoEingehaengt("pinnwand") ? (
|
||||
<PinnwandSteuerung laeuft={lauf.laeuft && aktivDemo === "pinnwand"} statisch={lauf.statisch} onZustand={setPinnwand} />
|
||||
) : null}
|
||||
</Suspense>
|
||||
|
||||
<div className="kp-spur" aria-hidden="true">
|
||||
{FUNKTIONEN.map((f, i) => (
|
||||
<div key={f.id} className="kp-marker" data-index={i} ref={(el) => (marker.current[i] = el)} />
|
||||
<div
|
||||
key={f.id}
|
||||
className={`kp-marker${f.demo ? " kp-marker--demo" : ""}`}
|
||||
data-index={i}
|
||||
ref={(el) => (marker.current[i] = el)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="kp-buehne" ref={buehne} data-app={app ? "ja" : "nein"}>
|
||||
<div className="wk-container kp-buehne__innen">
|
||||
<ol className="kp-liste" aria-label="Funktionen des Kundenportals">
|
||||
{FUNKTIONEN.map((f, i) => (
|
||||
<li key={f.id}>
|
||||
{FUNKTIONEN.map((f, i) => {
|
||||
const eintrag = (
|
||||
<button
|
||||
type="button"
|
||||
className="kp-eintrag"
|
||||
@@ -259,26 +400,49 @@ const Buehne = () => {
|
||||
>
|
||||
<span className="kp-eintrag__punkt" aria-hidden="true" />
|
||||
<span className="kp-eintrag__titel">{f.titel}</span>
|
||||
<span className="kp-eintrag__text">{f.unterschrift}</span>
|
||||
{/* ohne Maus (Tablet) heißt es „Tippen Sie“ statt „Zeigen Sie mit der Maus“ */}
|
||||
<span className="kp-eintrag__text">{!fein && f.unterschriftHandy ? f.unterschriftHandy : f.unterschrift}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
if (!f.demo) return <li key={f.id}>{eintrag}</li>;
|
||||
// die drei Funktionen stehen in einer Gruppe mit eigener Überschrift
|
||||
const erste = !FUNKTIONEN[i - 1]?.demo;
|
||||
return (
|
||||
<li key={f.id} className="kp-gruppe" data-erste={erste ? "ja" : undefined}>
|
||||
{erste ? <p className="kp-gruppe__titel">{GRUPPE_TITEL}</p> : null}
|
||||
{eintrag}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<div className="kp-geraete">
|
||||
<DesktopFenster className="kp-fenster">
|
||||
<DesktopFenster className="kp-fenster" adresse={FUNKTIONEN[aktiv].adresse}>
|
||||
<div className="kp-ebenen" aria-hidden={app || undefined}>
|
||||
{FUNKTIONEN.map((f, i) =>
|
||||
f.desktop && eingehaengt(i) ? (
|
||||
{FUNKTIONEN.map((f, i) => {
|
||||
if (!eingehaengt(i) || !(f.desktop || f.demo)) return null;
|
||||
return (
|
||||
<div
|
||||
key={f.id}
|
||||
className={`kp-lage${i === aktiv ? " is-aktiv" : ""}`}
|
||||
aria-hidden={i !== aktiv || undefined}
|
||||
// die Website selbst ist aria-hidden; die aktive Ebene trägt eine kurze Beschreibung
|
||||
role={f.beschreibung && i === aktiv ? "img" : undefined}
|
||||
aria-label={f.beschreibung && i === aktiv ? f.beschreibung : undefined}
|
||||
>
|
||||
<RahmenBild bild={desktopBild(f.desktop.name, f.desktop.alt)} />
|
||||
{f.desktop ? (
|
||||
<RahmenBild bild={desktopBild(f.desktop.name, f.desktop.alt)} />
|
||||
) : f.demo ? (
|
||||
// ohne gemeldeten Zustand steht die Website im Anfangszustand da (Chunk lädt parallel zum Taktgeber)
|
||||
<Suspense fallback={<Platzhalter art={f.demo} />}>
|
||||
{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}
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DesktopFenster>
|
||||
|
||||
@@ -297,10 +461,58 @@ const Buehne = () => {
|
||||
) : (
|
||||
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "(min-width: 1200px) 230px, 20vw")} />
|
||||
)}
|
||||
{f.demo ? (
|
||||
// Live-Ebene über dem Portal-Bild: Mitteilung bzw. Tipp-Finger, synchron zur Website
|
||||
<Suspense fallback={null}>
|
||||
{f.demo === "termin" && termin ? <TerminHandy zustand={termin} /> : null}
|
||||
{f.demo === "pinnwand" && pinnwand ? <PinnwandHandy zustand={pinnwand} punkt={AUFHAENGEN_PUNKT} /> : null}
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</HandyRahmen>
|
||||
|
||||
{/* unter dem MacBook, links neben dem iPhone: Hinweis auf das erfundene Beispiel und je Demo eine kleine Karte
|
||||
(Website-Analyse: die Diagnose, Terminplaner und Pinnwand: ein Hinweis zur Funktion) */}
|
||||
<div className="kp-unter">
|
||||
<div className="kp-beispiele">
|
||||
{FUNKTIONEN.filter((f) => f.demo).map((f) => (
|
||||
<p
|
||||
key={f.id}
|
||||
className="kp-beispiel"
|
||||
data-an={f.demo === aktivDemo ? "ja" : "nein"}
|
||||
aria-hidden={f.demo !== aktivDemo || undefined}
|
||||
>
|
||||
{beispielText(f)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="kp-karten">
|
||||
<div
|
||||
className="kp-diagnose"
|
||||
data-an={aktivDemo === "heatmap" ? "ja" : "nein"}
|
||||
aria-hidden={aktivDemo !== "heatmap" || undefined}
|
||||
{...(aktivDemo !== "heatmap" ? INERT : null)}
|
||||
>
|
||||
{demoEingehaengt("heatmap") ? (
|
||||
<Suspense fallback={null}>
|
||||
<HeatmapDiagnose zustand={heat ?? undefined} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
{FUNKTIONEN.filter((f) => f.demo && f.hinweis).map((f) => (
|
||||
<p
|
||||
key={f.id}
|
||||
className="kp-hinweis"
|
||||
data-an={f.demo === aktivDemo ? "ja" : "nein"}
|
||||
aria-hidden={f.demo !== aktivDemo || undefined}
|
||||
>
|
||||
{f.hinweis}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,20 +548,112 @@ const LangeKarte = ({ f }: { f: Funktion }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Kartenrahmen einer Demo-Karte: Handy mit der Website, Titel, Unterschrift und der Hinweis auf das erfundene Beispiel. */
|
||||
const DemoKarteRahmen = ({
|
||||
f,
|
||||
geraetRef,
|
||||
nachher,
|
||||
children,
|
||||
}: {
|
||||
f: Funktion;
|
||||
/** Das Handy der Karte: nach seiner Sichtbarkeit richtet sich der Ablauf (die Karte selbst kann höher sein als der Bildschirm) */
|
||||
geraetRef: RefObject<HTMLDivElement>;
|
||||
/** Unter dem Text (Heatmap: die Diagnose) */
|
||||
nachher?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<div className="kp-karte kp-karte--demo" data-demo={f.demo}>
|
||||
<div className="kp-karte__geraet" ref={geraetRef}>
|
||||
<HandyRahmen className="kp-karte__handy">
|
||||
<Suspense fallback={f.demo ? <Platzhalter art={f.demo} /> : null}>{children}</Suspense>
|
||||
</HandyRahmen>
|
||||
</div>
|
||||
<h3 className="wk-h3 kp-karte__titel">{f.titel}</h3>
|
||||
<p className="wk-muted kp-karte__text">{f.unterschriftHandy ?? f.unterschrift}</p>
|
||||
<p className="kp-karte__beispiel">{beispielText(f)}</p>
|
||||
{nachher}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Eigener Ablauf je Karte: Die Website wird geladen, sobald das Handy der Karte (angeschnitten) in Sicht kommt,
|
||||
* und läuft, solange es zu mindestens 60 % im Bild ist. Maßgeblich ist das Handy, nicht die ganze Karte:
|
||||
* Die Heatmap-Karte ist mit der Diagnose höher als kleine Bildschirme, 60 % der Karte würden dort nie erreicht.
|
||||
*/
|
||||
const useKarte = (art: DemoArt) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const nah = useImBild(ref, 0);
|
||||
const imBild = useImBild(ref, 0.6);
|
||||
const [gesehen, setGesehen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (nah) setGesehen(true);
|
||||
}, [nah]);
|
||||
// alle Chunks der Demo gleichzeitig laden, sobald die Karte angeschnitten in Sicht kommt
|
||||
useEffect(() => {
|
||||
if (gesehen) VORLADEN[art].forEach((vorladen) => vorladen());
|
||||
}, [gesehen, art]);
|
||||
const lauf = useDemoLauf(true, imBild);
|
||||
return { ref, gesehen, ...lauf };
|
||||
};
|
||||
|
||||
const TerminKarte = ({ f }: { f: Funktion }) => {
|
||||
const k = useKarte("termin");
|
||||
const [z, setZ] = useState<TerminZustand | null>(null);
|
||||
return (
|
||||
<DemoKarteRahmen f={f} geraetRef={k.ref}>
|
||||
{k.gesehen ? <TerminSteuerung laeuft={k.laeuft} statisch={k.statisch} onZustand={setZ} /> : null}
|
||||
{k.gesehen ? <TerminSeite zustand={z ?? undefined} variante="handy" /> : null}
|
||||
</DemoKarteRahmen>
|
||||
);
|
||||
};
|
||||
|
||||
const PinnwandKarte = ({ f }: { f: Funktion }) => {
|
||||
const k = useKarte("pinnwand");
|
||||
const [z, setZ] = useState<PinnwandZustand | null>(null);
|
||||
return (
|
||||
<DemoKarteRahmen f={f} geraetRef={k.ref}>
|
||||
{k.gesehen ? <PinnwandSteuerung laeuft={k.laeuft} statisch={k.statisch} onZustand={setZ} /> : null}
|
||||
{k.gesehen ? <PinnwandSeite 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");
|
||||
const [z, setZ] = useState<HeatmapZustand | null>(null);
|
||||
return (
|
||||
<DemoKarteRahmen
|
||||
f={f}
|
||||
geraetRef={k.ref}
|
||||
nachher={
|
||||
<div className="kp-karte__diagnose">
|
||||
<Suspense fallback={null}>{k.gesehen ? <HeatmapDiagnose zustand={z ?? undefined} kompakt /> : null}</Suspense>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{k.gesehen ? <HeatmapSteuerung laeuft={k.laeuft} statisch={k.statisch} aktiv onZustand={setZ} /> : null}
|
||||
{k.gesehen ? <HeatmapSeite zustand={z ?? undefined} variante="handy" /> : null}
|
||||
</DemoKarteRahmen>
|
||||
);
|
||||
};
|
||||
|
||||
const HandyReihe = () => (
|
||||
<div className="kp-reihe">
|
||||
<WischReihe ariaLabel="Sechs Funktionen des Kundenportals, seitlich wischen">
|
||||
{FUNKTIONEN.map((f) =>
|
||||
f.lang ? (
|
||||
<LangeKarte key={f.id} f={f} />
|
||||
) : (
|
||||
<WischReihe ariaLabel="Acht Funktionen des Kundenportals, seitlich wischen">
|
||||
{FUNKTIONEN.map((f) => {
|
||||
if (f.lang) return <LangeKarte 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} />;
|
||||
return (
|
||||
<div key={f.id} className="kp-karte">
|
||||
<HandyRahmen className="kp-karte__handy" bild={handyBild(f.handy.name, f.handy.alt, "250px")} />
|
||||
<h3 className="wk-h3 kp-karte__titel">{f.titel}</h3>
|
||||
<p className="wk-muted kp-karte__text">{f.unterschrift}</p>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</WischReihe>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Gemeinsame Teile der Funktions-Demos: Zeiger und Klickwelle.
|
||||
* Grundzustand statisch; Gleiten und Welle nur ohne prefers-reduced-motion (nur transform/opacity).
|
||||
*/
|
||||
|
||||
.dz-zeiger {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
}
|
||||
.dz-zeiger[data-sichtbar="nein"] {
|
||||
opacity: 0;
|
||||
}
|
||||
.dz-pfeil {
|
||||
display: block;
|
||||
transform: scale(var(--dz-g, 1.6));
|
||||
transform-origin: 0 0;
|
||||
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.35));
|
||||
}
|
||||
.dz-finger {
|
||||
position: absolute;
|
||||
left: -14px;
|
||||
top: -14px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.55);
|
||||
border: 2px solid rgb(0 0 0 / 0.25);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
.dz-welle {
|
||||
position: absolute;
|
||||
left: calc(-24px * var(--dz-g, 1.6));
|
||||
top: calc(-24px * var(--dz-g, 1.6));
|
||||
width: calc(48px * var(--dz-g, 1.6));
|
||||
height: calc(48px * var(--dz-g, 1.6));
|
||||
border-radius: 50%;
|
||||
border: calc(3px * var(--dz-g, 1.6)) solid rgb(229 37 28 / 0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.dz-zeiger {
|
||||
transition:
|
||||
transform 750ms cubic-bezier(0.45, 0, 0.2, 1),
|
||||
opacity 300ms ease;
|
||||
}
|
||||
.dz-welle {
|
||||
animation: dz-welle 650ms ease-out both;
|
||||
}
|
||||
}
|
||||
@keyframes dz-welle {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(1.4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useRef, useState, useSyncExternalStore, type RefObject } from "react";
|
||||
import "./gemeinsam.css";
|
||||
|
||||
/**
|
||||
* Gemeinsame Bausteine der Funktions-Demos im Abschnitt Kundenportal (Spezifikation 2026-10-04-funktionen-demos).
|
||||
* Jede Demo zeigt eine erfundene Kunden-Website im MacBook (1512 x 893) oder im iPhone (402 x 874,
|
||||
* Safe Area oben 59 / unten 34) und spielt einen kurzen Ablauf ab, solange ihr Schritt aktiv und sichtbar ist.
|
||||
* Bewegung nur mit transform/opacity und nur ohne prefers-reduced-motion; sonst zeigt jede Demo ihren Endzustand.
|
||||
*/
|
||||
|
||||
export type DemoVariante = "desktop" | "handy";
|
||||
|
||||
/** Designgrößen der Bildschirmflächen (wie DeviceFrames: MacBook-Safari-Fläche und iPhone-Inhalt). */
|
||||
export const FLAECHE = {
|
||||
desktop: { breite: 1512, hoehe: 893 },
|
||||
handy: { breite: 402, hoehe: 874, oben: 59, unten: 34 },
|
||||
} as const;
|
||||
|
||||
// ── Medienabfragen ─────────────────────────────────────────────────────────
|
||||
|
||||
const abonniere = (abfrage: string) => (melden: () => void) => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return () => {};
|
||||
const mq = window.matchMedia(abfrage);
|
||||
mq.addEventListener?.("change", melden);
|
||||
return () => mq.removeEventListener?.("change", melden);
|
||||
};
|
||||
const lies = (abfrage: string, standard: boolean) => () =>
|
||||
typeof window !== "undefined" && window.matchMedia ? window.matchMedia(abfrage).matches : standard;
|
||||
|
||||
/** true, wenn Bewegung erlaubt ist (prefers-reduced-motion: no-preference). Ohne matchMedia: keine Bewegung. */
|
||||
export const useBewegungErlaubt = () => {
|
||||
const abfrage = "(prefers-reduced-motion: no-preference)";
|
||||
return useSyncExternalStore(abonniere(abfrage), lies(abfrage, false), () => false);
|
||||
};
|
||||
|
||||
/** true bei einer Maus (Hover-Effekte nur dann, Spezifikation 8.4). */
|
||||
export const useFeinerZeiger = () => {
|
||||
const abfrage = "(hover: hover) and (pointer: fine)";
|
||||
return useSyncExternalStore(abonniere(abfrage), lies(abfrage, false), () => false);
|
||||
};
|
||||
|
||||
/** true, solange der Browser-Tab sichtbar ist (im Hintergrund läuft nichts). */
|
||||
export const useTabSichtbar = () =>
|
||||
useSyncExternalStore(
|
||||
(melden) => {
|
||||
if (typeof document === "undefined") return () => {};
|
||||
document.addEventListener("visibilitychange", melden);
|
||||
return () => document.removeEventListener("visibilitychange", melden);
|
||||
},
|
||||
() => (typeof document === "undefined" ? true : document.visibilityState !== "hidden"),
|
||||
() => true,
|
||||
);
|
||||
|
||||
/** true, solange das Element zu mindestens `anteil` im Bild ist. Ohne IntersectionObserver: false. */
|
||||
export const useImBild = (ref: RefObject<Element | null>, anteil = 0.35) => {
|
||||
const [imBild, setImBild] = useState(false);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || typeof IntersectionObserver === "undefined") return;
|
||||
const beobachter = new IntersectionObserver(([e]) => setImBild(e.isIntersecting), { threshold: anteil });
|
||||
beobachter.observe(el);
|
||||
return () => beobachter.disconnect();
|
||||
}, [ref, anteil]);
|
||||
return imBild;
|
||||
};
|
||||
|
||||
// ── Ablauf ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Taktgeber einer Demo: geht die Schritte mit ihren Dauern (ms) durch, solange `laeuft`.
|
||||
* Hört `laeuft` auf, springt der Ablauf auf Schritt 0 zurück (beim nächsten Mal beginnt die Geschichte von vorn).
|
||||
* Nach dem letzten Schritt: mit `schleife` wieder Schritt 0, sonst bleibt der letzte stehen.
|
||||
* Rückgabe: Index des aktuellen Schritts und ein Zähler der Durchläufe (für Neuaufbau, z. B. Heatmap leeren).
|
||||
*/
|
||||
export const useAblauf = (laeuft: boolean, dauern: readonly number[], schleife = true) => {
|
||||
const [stand, setStand] = useState({ schritt: 0, runde: 0 });
|
||||
const dauernRef = useRef(dauern);
|
||||
dauernRef.current = dauern;
|
||||
|
||||
useEffect(() => {
|
||||
if (!laeuft) {
|
||||
setStand((s) => (s.schritt === 0 ? s : { schritt: 0, runde: s.runde + 1 }));
|
||||
return;
|
||||
}
|
||||
const liste = dauernRef.current;
|
||||
const letzter = liste.length - 1;
|
||||
if (stand.schritt >= letzter && !schleife) return;
|
||||
const t = window.setTimeout(() => {
|
||||
setStand((s) =>
|
||||
s.schritt >= letzter ? { schritt: 0, runde: s.runde + 1 } : { schritt: s.schritt + 1, runde: s.runde },
|
||||
);
|
||||
}, liste[stand.schritt] ?? 1000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [laeuft, stand.schritt, schleife]);
|
||||
|
||||
return stand;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ob eine Demo gerade abspielen darf: Schritt aktiv, im Bild, Tab sichtbar und Bewegung erlaubt.
|
||||
* `statisch` = keine Bewegung erlaubt: dann zeigt die Demo ihren Endzustand.
|
||||
*/
|
||||
export const useDemoLauf = (aktiv: boolean, imBild = true) => {
|
||||
const bewegung = useBewegungErlaubt();
|
||||
const tab = useTabSichtbar();
|
||||
return { laeuft: aktiv && imBild && tab && bewegung, statisch: !bewegung };
|
||||
};
|
||||
|
||||
// ── Zeiger und Klickwelle ─────────────────────────────────────────────────
|
||||
|
||||
type ZeigerProps = {
|
||||
/** Spitze des Zeigers in Designpunkten der Fläche */
|
||||
x: number;
|
||||
y: number;
|
||||
/** maus = Pfeil (MacBook), finger = runder Tipp-Punkt (iPhone) */
|
||||
art?: "maus" | "finger";
|
||||
/** Jede Änderung dieser Zahl löst eine Klickwelle aus (0 = noch kein Klick) */
|
||||
klick?: number;
|
||||
sichtbar?: boolean;
|
||||
/** Maßstab des Zeigers (1 = 30 Designpunkte hoch); im MacBook wirkt 1.6 natürlich, weil alles verkleinert ist */
|
||||
groesse?: number;
|
||||
};
|
||||
|
||||
/** Ein dekorativer Mauszeiger, der per transform zu (x, y) gleitet; Klicks zeigen eine kurze Welle. */
|
||||
export const DemoZeiger = ({ x, y, art = "maus", klick = 0, sichtbar = true, groesse = 1.6 }: ZeigerProps) => (
|
||||
<div
|
||||
className="dz-zeiger"
|
||||
data-art={art}
|
||||
data-sichtbar={sichtbar ? "ja" : "nein"}
|
||||
style={{ transform: `translate(${x}px, ${y}px)`, ["--dz-g" as string]: groesse }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{klick > 0 ? <span key={klick} className="dz-welle" /> : null}
|
||||
{art === "maus" ? (
|
||||
<svg className="dz-pfeil" viewBox="0 0 20 30" width="20" height="30">
|
||||
<path
|
||||
d="M1.5 1.5v22.2l5.4-5.1 3.7 8.6 3.6-1.6-3.6-8.4h7.6z"
|
||||
fill="#111"
|
||||
stroke="#fff"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span className="dz-finger" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Hilfen ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Deterministischer Zufall (mulberry32): gleiche Saat, gleiche Folge – Demos sehen bei jedem Laden gleich aus. */
|
||||
export const zufall = (saat: number) => {
|
||||
let a = saat >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
};
|
||||
|
||||
/** Zahl im deutschen Format (1.284). */
|
||||
export const zahl = (n: number) => new Intl.NumberFormat("de-DE").format(n);
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Heatmap-Zeichnung wie im echten Overlay der WEBklar-Analyse (Kundenportal analytics/client/h.js):
|
||||
* jeder Klick ist ein weicher Fleck (radialer Verlauf, Deckkraft 0,25), überlagerte Flecken werden über
|
||||
* die Farbskala transparent -> blau -> grün -> gelb -> rot eingefärbt. So sieht die Demo aus wie das Produkt.
|
||||
*/
|
||||
|
||||
/** Klickpunkt relativ zur Fläche (0 bis 1). */
|
||||
export type HeatPunkt = { x: number; y: number };
|
||||
|
||||
let skala: Uint8ClampedArray | null = null;
|
||||
const farbskala = () => {
|
||||
if (skala) return skala;
|
||||
const c = document.createElement("canvas");
|
||||
c.width = 256;
|
||||
c.height = 1;
|
||||
const g = c.getContext("2d");
|
||||
if (!g) return null;
|
||||
const lg = g.createLinearGradient(0, 0, 256, 0);
|
||||
lg.addColorStop(0.25, "rgb(0,0,255)");
|
||||
lg.addColorStop(0.55, "rgb(0,255,0)");
|
||||
lg.addColorStop(0.85, "rgb(255,255,0)");
|
||||
lg.addColorStop(1, "rgb(255,0,0)");
|
||||
g.fillStyle = lg;
|
||||
g.fillRect(0, 0, 256, 1);
|
||||
skala = g.getImageData(0, 0, 256, 1).data;
|
||||
return skala;
|
||||
};
|
||||
|
||||
/**
|
||||
* Zeichnet alle Punkte neu auf die Leinwand (ihre width/height in Pixeln bestimmen die Auflösung).
|
||||
* radius in Leinwand-Pixeln. Ohne 2D-Kontext (jsdom) passiert nichts.
|
||||
*/
|
||||
export const zeichneHeatmap = (leinwand: HTMLCanvasElement, punkte: readonly HeatPunkt[], radius: number) => {
|
||||
const g = leinwand.getContext("2d", { willReadFrequently: true });
|
||||
const farben = farbskala();
|
||||
if (!g || !farben) return;
|
||||
const b = leinwand.width;
|
||||
const h = leinwand.height;
|
||||
g.clearRect(0, 0, b, h);
|
||||
for (const p of punkte) {
|
||||
const x = p.x * b;
|
||||
const y = p.y * h;
|
||||
const rg = g.createRadialGradient(x, y, 0, x, y, radius);
|
||||
rg.addColorStop(0, "rgba(0,0,0,0.25)");
|
||||
rg.addColorStop(1, "rgba(0,0,0,0)");
|
||||
g.fillStyle = rg;
|
||||
g.fillRect(x - radius, y - radius, radius * 2, radius * 2);
|
||||
}
|
||||
const bild = g.getImageData(0, 0, b, h);
|
||||
const d = bild.data;
|
||||
for (let i = 3; i < d.length; i += 4) {
|
||||
const a = d[i];
|
||||
if (!a) continue;
|
||||
const k = Math.min(255, a * 3) * 4;
|
||||
d[i - 3] = farben[k];
|
||||
d[i - 2] = farben[k + 1];
|
||||
d[i - 1] = farben[k + 2];
|
||||
d[i] = Math.min(200, 60 + a);
|
||||
}
|
||||
g.putImageData(bild, 0, 0);
|
||||
};
|
||||
|
||||
/** Rechteck einer Klickzone relativ zur Fläche (0 bis 1). */
|
||||
export type ZonenRechteck = { x: number; y: number; b: number; h: number };
|
||||
|
||||
/**
|
||||
* Verteilt `anzahl` Punkte in einer Zone: dicht zur Mitte hin (Summe zweier Zufallszahlen), wie echte Klicks
|
||||
* auf einen Knopf. `zufall` liefert Zahlen in [0, 1).
|
||||
*/
|
||||
export const punkteInZone = (zone: ZonenRechteck, anzahl: number, zufall: () => number): HeatPunkt[] => {
|
||||
const punkte: HeatPunkt[] = [];
|
||||
for (let i = 0; i < anzahl; i++) {
|
||||
const rx = (zufall() + zufall()) / 2;
|
||||
const ry = (zufall() + zufall()) / 2;
|
||||
punkte.push({ x: zone.x + rx * zone.b, y: zone.y + ry * zone.h });
|
||||
}
|
||||
return punkte;
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { FocusEvent } from "react";
|
||||
import { useFeinerZeiger } from "../gemeinsam";
|
||||
import { HEATMAP_START, type HeatmapZustand } from "./ablauf";
|
||||
import { SUMME_KLICKS, TOP_ZONE, anteil, rangliste, zoneNach } from "./zonen";
|
||||
import "./heatmap.css";
|
||||
|
||||
/**
|
||||
* Die Diagnose unter dem MacBook (Demo Website-Analyse, Spezifikation 3.2): die vier meistgeklickten Zonen mit
|
||||
* Balken und Prozent, darunter ein beobachtender Satz zur gewählten Zone (ohne Auswahl: zur meistgeklickten).
|
||||
* Normaler Seitenmaßstab mit den Seitentoken, funktioniert hell und dunkel. Die Zeilen sind Knöpfe:
|
||||
* Fokus, Hover oder Klick wählen die Zone, auf der Website erscheint dann der Rahmen.
|
||||
* `kompakt` ist für die Handy-Karte: Satz oben, nur die drei meistgeklickten Zonen.
|
||||
*/
|
||||
|
||||
type Props = { zustand?: HeatmapZustand; kompakt?: boolean };
|
||||
|
||||
const zahl = (n: number) => new Intl.NumberFormat("de-DE").format(n);
|
||||
|
||||
/** Tastaturfokus? (Mausklicks sollen nicht doppelt wählen.) Wo :focus-visible fehlt, zählt jeder Fokus. */
|
||||
const tastaturFokus = (el: HTMLElement) => {
|
||||
try {
|
||||
return el.matches(":focus-visible");
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const HeatmapDiagnose = ({ zustand = HEATMAP_START, kompakt = false }: Props) => {
|
||||
const fein = useFeinerZeiger();
|
||||
const zeilen = rangliste(kompakt ? 3 : 4);
|
||||
const gewaehlt = zoneNach(zustand.auswahl) ?? TOP_ZONE;
|
||||
const spitze = zeilen[0].klicks;
|
||||
|
||||
return (
|
||||
<section className={`hmd${kompakt ? " hmd--kompakt" : ""}`} aria-label="Diagnose" data-auswahl={gewaehlt.id}>
|
||||
<div className="hmd__kopf">
|
||||
<p className="hmd__titel">Diagnose</p>
|
||||
<span className="hmd__quelle">{kompakt ? "Beispieldaten" : "letzte 30 Tage · Beispieldaten"}</span>
|
||||
<span className="hmd__summe">{zahl(SUMME_KLICKS)} Klicks</span>
|
||||
</div>
|
||||
<div className="hmd__koerper">
|
||||
<ul className="hmd__liste" aria-label="Meistgeklickte Stellen der Seite">
|
||||
{zeilen.map((z) => {
|
||||
const an = zustand.auswahl === z.id;
|
||||
return (
|
||||
<li key={z.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="hmd__zeile"
|
||||
aria-pressed={an}
|
||||
onClick={() => zustand.waehle(!fein && an ? null : z.id)}
|
||||
onFocus={(e: FocusEvent<HTMLButtonElement>) => {
|
||||
if (tastaturFokus(e.currentTarget)) zustand.waehle(z.id);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (an) zustand.waehle(null);
|
||||
}}
|
||||
onMouseEnter={fein ? () => zustand.waehle(z.id) : undefined}
|
||||
onMouseLeave={fein ? () => zustand.waehle(null) : undefined}
|
||||
>
|
||||
<span className="hmd__name">
|
||||
<span>{z.name}</span>
|
||||
{z.tot ? <span className="hmd__tot">toter Klick</span> : null}
|
||||
</span>
|
||||
<span className="hmd__spur" aria-hidden="true">
|
||||
<span className="hmd__balken" style={{ transform: `scaleX(${(z.klicks / spitze).toFixed(3)})` }} />
|
||||
</span>
|
||||
<span className="hmd__prozent">{anteil(z)}{"\u00a0"}%</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="hmd__rechts">
|
||||
<p className="hmd__satz" aria-live="polite" aria-atomic="true" data-tot={gewaehlt.tot ? "ja" : "nein"}>
|
||||
{gewaehlt.satz}
|
||||
</p>
|
||||
{/* Farbskala der Heatmap (wie in heat.ts: blau, grün, gelb, rot) */}
|
||||
<div className="hmd__legende" aria-hidden="true">
|
||||
<span>wenig</span>
|
||||
<span className="hmd__skala" />
|
||||
<span>viel</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeatmapDiagnose;
|
||||
export { HeatmapDiagnose };
|
||||
@@ -0,0 +1,691 @@
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type MouseEvent, type RefObject } from "react";
|
||||
import { DemoZeiger, useFeinerZeiger, zufall, type DemoVariante } from "../gemeinsam";
|
||||
import { punkteInZone, zeichneHeatmap, type HeatPunkt } from "../heat";
|
||||
import { HEATMAP_START, type HeatmapZustand } from "./ablauf";
|
||||
import { FLAECHE_GROESSE, TOP_ZONE, ZONEN, ZONEN_LAYOUT, schildText, zoneNach, type Zone, type PunktRechteck } from "./zonen";
|
||||
import "./heatmap.css";
|
||||
|
||||
/**
|
||||
* Die erfundene Beispiel-Website „Boulderhalle Überhang“ (Demo Website-Analyse, Spezifikation 3.2) mit der
|
||||
* Heatmap darüber. 'desktop' wird in 1512 x 893 gesetzt (MacBook), 'handy' in 402 x 874 (iPhone, oben 59 und
|
||||
* unten 34 frei). Die Seite ist reine Dekoration: aria-hidden, keine fokussierbaren Elemente.
|
||||
* Alle Bilder sind selbst gezeichnete SVG/CSS-Grafiken, nichts wird nachgeladen.
|
||||
*/
|
||||
|
||||
/** `zustand` fehlt, solange der Taktgeber noch lädt: dann steht die Website im Anfangszustand da. */
|
||||
type Props = { zustand?: HeatmapZustand; variante: DemoVariante };
|
||||
|
||||
// ── Routenbild: selbst gezeichnete Kletterwand ────────────────────────────────
|
||||
|
||||
type Farbe = "lime" | "orange" | "pink" | "cyan";
|
||||
const FARBEN: Record<Farbe, { hell: string; dunkel: string }> = {
|
||||
lime: { hell: "#c6f432", dunkel: "#8aa81a" },
|
||||
orange: { hell: "#ff7a1a", dunkel: "#b8500a" },
|
||||
pink: { hell: "#ff4f9a", dunkel: "#b82a6b" },
|
||||
cyan: { hell: "#2ad1ff", dunkel: "#0e86ad" },
|
||||
};
|
||||
|
||||
/** Formen von Klettergriffen um den Nullpunkt (etwa 70 breit). */
|
||||
const FORMEN: Record<string, string> = {
|
||||
a: "M-30 8C-35-14-10-33 14-29C35-25 38-2 30 14C22 31-25 33-30 8Z",
|
||||
b: "M-35 2C-31-25 31-27 35 1C37 22 21 30 0 30C-22 30-37 23-35 2Z",
|
||||
c: "M-37-8Q0-24 37-8Q42 13 0 15Q-42 13-37-8Z",
|
||||
d: "M0-27C16-27 28-15 28 0C28 15 16 27 0 27C-16 27-28 15-28 0C-28-15-16-27 0-27Z",
|
||||
e: "M-25 20C-33-4-8-31 14-27C35-23 31 8 23 22C9 31-14 31-25 20Z",
|
||||
};
|
||||
|
||||
/** [x, y, Form, Größe, Drehung]; x und y als Anteil der Wand (0 bis 1). */
|
||||
type Griff = readonly [number, number, string, number, number];
|
||||
const ROUTEN: { farbe: Farbe; grad: string; griffe: readonly Griff[] }[] = [
|
||||
{
|
||||
farbe: "lime",
|
||||
grad: "4",
|
||||
griffe: [
|
||||
[0.14, 0.84, "b", 1.1, -8],
|
||||
[0.22, 0.72, "a", 0.9, 20],
|
||||
[0.13, 0.6, "c", 1, -15],
|
||||
[0.24, 0.47, "e", 0.95, 35],
|
||||
[0.15, 0.34, "a", 0.9, -30],
|
||||
[0.26, 0.22, "d", 0.9, 0],
|
||||
[0.18, 0.1, "b", 1.15, 10],
|
||||
],
|
||||
},
|
||||
{
|
||||
farbe: "orange",
|
||||
grad: "5",
|
||||
griffe: [
|
||||
[0.36, 0.86, "c", 1, 10],
|
||||
[0.44, 0.74, "a", 1, -20],
|
||||
[0.34, 0.62, "b", 0.9, 15],
|
||||
[0.47, 0.5, "e", 1, -40],
|
||||
[0.38, 0.37, "d", 0.85, 0],
|
||||
[0.5, 0.25, "a", 0.95, 25],
|
||||
[0.42, 0.12, "b", 1.1, -5],
|
||||
],
|
||||
},
|
||||
{
|
||||
farbe: "pink",
|
||||
grad: "6",
|
||||
griffe: [
|
||||
[0.6, 0.82, "a", 1, 5],
|
||||
[0.68, 0.71, "e", 0.9, -25],
|
||||
[0.57, 0.58, "c", 1.05, 20],
|
||||
[0.7, 0.46, "b", 1, -10],
|
||||
[0.61, 0.34, "a", 0.9, 40],
|
||||
[0.73, 0.22, "d", 0.9, 0],
|
||||
[0.64, 0.1, "c", 1.1, -12],
|
||||
],
|
||||
},
|
||||
{
|
||||
farbe: "cyan",
|
||||
grad: "7",
|
||||
griffe: [
|
||||
[0.83, 0.85, "b", 1, -5],
|
||||
[0.9, 0.72, "a", 0.9, 30],
|
||||
[0.79, 0.6, "e", 1, -15],
|
||||
[0.91, 0.48, "c", 0.95, 10],
|
||||
[0.8, 0.36, "a", 1, -35],
|
||||
[0.9, 0.24, "b", 0.9, 15],
|
||||
[0.84, 0.11, "d", 1.1, 0],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Kleine graue Tritte zwischen den Routen: [x, y, Form, Größe, Drehung]. */
|
||||
const TRITTE: readonly Griff[] = [
|
||||
[0.06, 0.66, "d", 0.4, 0],
|
||||
[0.31, 0.8, "c", 0.45, 20],
|
||||
[0.52, 0.64, "d", 0.4, 0],
|
||||
[0.47, 0.88, "d", 0.42, 0],
|
||||
[0.77, 0.77, "c", 0.45, -15],
|
||||
[0.96, 0.62, "d", 0.4, 0],
|
||||
[0.31, 0.5, "d", 0.38, 0],
|
||||
[0.55, 0.4, "c", 0.45, 30],
|
||||
[0.74, 0.4, "d", 0.38, 0],
|
||||
[0.07, 0.4, "c", 0.45, -20],
|
||||
[0.34, 0.2, "d", 0.38, 0],
|
||||
[0.56, 0.2, "d", 0.4, 0],
|
||||
[0.96, 0.34, "c", 0.45, 25],
|
||||
[0.06, 0.2, "d", 0.38, 0],
|
||||
];
|
||||
|
||||
const Griffe = ({ griff, farbe, mass }: { griff: Griff; farbe: Farbe | "grau"; mass: number }) => {
|
||||
const [x, y, form, groesse, drehung] = griff;
|
||||
const f = farbe === "grau" ? { hell: "#b9b6ae", dunkel: "#7d7a73" } : FARBEN[farbe];
|
||||
const pfad = FORMEN[form];
|
||||
const g = groesse * mass;
|
||||
return (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<path d={pfad} transform={`translate(${5 * g} ${8 * g}) rotate(${drehung}) scale(${g})`} fill="#000" opacity="0.38" />
|
||||
<g transform={`rotate(${drehung}) scale(${g})`}>
|
||||
<path d={pfad} fill={f.dunkel} transform="translate(0 4)" />
|
||||
<path d={pfad} fill={f.hell} />
|
||||
<ellipse cx="-9" cy="-10" rx="13" ry="6" fill="#fff" opacity="0.38" transform="rotate(-24)" />
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Die Wand als SVG. b und h sind die Maße der Zeichnung, `klein` ist die Handy-Fassung (weniger, kleinere Griffe). */
|
||||
const Routenwand = ({ b, h, klein }: { b: number; h: number; klein?: boolean }) => {
|
||||
const id = useId().replace(/[^a-zA-Z0-9]/g, "");
|
||||
// flache Wand (Desktop, 260 hoch): kleinere Griffe, sonst drängen sie sich
|
||||
const mass = klein ? 0.5 : h < 300 ? 0.68 : 1;
|
||||
const px = (x: number) => x * b;
|
||||
const py = (y: number) => h * (0.17 + (y - 0.1) * 0.9);
|
||||
const matte = h * 0.93;
|
||||
return (
|
||||
<svg
|
||||
className="hm-wand__svg"
|
||||
viewBox={`0 0 ${b} ${h}`}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`${id}w`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#3b3b41" />
|
||||
<stop offset="1" stopColor="#222226" />
|
||||
</linearGradient>
|
||||
<pattern id={`${id}p`} width={klein ? 16 : 32} height={klein ? 16 : 32} patternUnits="userSpaceOnUse">
|
||||
<circle cx={klein ? 8 : 16} cy={klein ? 8 : 16} r={klein ? 1.3 : 2.6} fill="#151517" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width={b} height={h} fill={`url(#${id}w)`} />
|
||||
<rect width={b} height={h} fill={`url(#${id}p)`} opacity="0.9" />
|
||||
{/* Plattenfugen */}
|
||||
<g stroke="#141416" strokeWidth={klein ? 1.5 : 3}>
|
||||
<path d={`M${b * 0.25} 0V${matte}M${b * 0.5} 0V${matte}M${b * 0.75} 0V${matte}M0 ${h * 0.5}H${b}`} />
|
||||
</g>
|
||||
<g stroke="#4a4a52" strokeWidth={klein ? 0.8 : 1.5} opacity="0.6">
|
||||
<path
|
||||
d={`M${b * 0.25 + 3} 0V${matte}M${b * 0.5 + 3} 0V${matte}M${b * 0.75 + 3} 0V${matte}M0 ${h * 0.5 + 3}H${b}`}
|
||||
/>
|
||||
</g>
|
||||
{/* Dach: die Wand hängt über */}
|
||||
<path d={`M0 0H${b}V${h * 0.05}L0 ${h * 0.12}Z`} fill="#18181b" />
|
||||
<path d={`M0 ${h * 0.12}L${b} ${h * 0.05}`} stroke="#4a4a52" strokeWidth={klein ? 1 : 2} />
|
||||
{/* Matte */}
|
||||
<rect y={matte} width={b} height={h - matte} fill="#1b2633" />
|
||||
<path d={`M0 ${matte}H${b}`} stroke="#35506b" strokeWidth={klein ? 1.5 : 3} />
|
||||
<path
|
||||
d={`M${b * 0.2} ${matte}V${h}M${b * 0.4} ${matte}V${h}M${b * 0.6} ${matte}V${h}M${b * 0.8} ${matte}V${h}`}
|
||||
stroke="#121a24"
|
||||
strokeWidth={klein ? 1 : 2}
|
||||
/>
|
||||
|
||||
{/* Tritte */}
|
||||
{TRITTE.filter((_, i) => !klein || i % 2 === 0).map((t, i) => (
|
||||
<Griffe key={`t${i}`} griff={[px(t[0]), py(t[1]), t[2], t[3], t[4]]} farbe="grau" mass={mass} />
|
||||
))}
|
||||
|
||||
{ROUTEN.map((r) => {
|
||||
const griffe = klein ? r.griffe.filter((_, i) => i % 2 === 0 || i === r.griffe.length - 1) : r.griffe;
|
||||
const start = griffe[0];
|
||||
const oben = griffe[griffe.length - 1];
|
||||
return (
|
||||
<g key={r.farbe}>
|
||||
{griffe.map((g, i) => (
|
||||
<g key={i}>
|
||||
{/* Kreidespur */}
|
||||
<ellipse
|
||||
cx={px(g[0]) - 8 * mass}
|
||||
cy={py(g[1]) - 14 * mass}
|
||||
rx={30 * mass}
|
||||
ry={15 * mass}
|
||||
fill="#fff"
|
||||
opacity="0.07"
|
||||
/>
|
||||
<Griffe griff={[px(g[0]), py(g[1]), g[2], g[3], g[4]]} farbe={r.farbe} mass={mass} />
|
||||
</g>
|
||||
))}
|
||||
{/* Schwierigkeit am Startgriff */}
|
||||
<g transform={`translate(${px(start[0]) - 54 * mass} ${py(start[1]) + 6 * mass})`}>
|
||||
<circle r={19 * mass} fill="#f3efe6" />
|
||||
<circle r={19 * mass} fill="none" stroke={FARBEN[r.farbe].hell} strokeWidth={5 * mass} />
|
||||
<text
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={22 * mass}
|
||||
fontWeight="700"
|
||||
fill="#141414"
|
||||
fontFamily="inherit"
|
||||
>
|
||||
{r.grad}
|
||||
</text>
|
||||
</g>
|
||||
{/* Ring um den Zielgriff */}
|
||||
<circle
|
||||
cx={px(oben[0])}
|
||||
cy={py(oben[1])}
|
||||
r={46 * mass}
|
||||
fill="none"
|
||||
stroke={FARBEN[r.farbe].hell}
|
||||
strokeWidth={3 * mass}
|
||||
strokeDasharray={`${9 * mass} ${9 * mass}`}
|
||||
opacity="0.8"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<rect width={b} height={h} fill="none" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Kleine Zeichnungen ───────────────────────────────────────────────────────
|
||||
|
||||
const LogoZeichen = () => (
|
||||
<svg className="hm-logo__zeichen" viewBox="0 0 56 56" aria-hidden="true" focusable="false">
|
||||
<rect width="56" height="56" rx="16" fill="#c6f432" />
|
||||
<path d="M13 44V22L33 11L44 19V44Z" fill="#141414" />
|
||||
<path d="M13 22L33 11" stroke="#c6f432" strokeWidth="2" opacity="0.5" />
|
||||
<circle cx="25" cy="33" r="4.5" fill="#ff7a1a" />
|
||||
<circle cx="35" cy="24" r="3.6" fill="#ff4f9a" />
|
||||
<circle cx="33" cy="38" r="3.4" fill="#2ad1ff" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Pfeil = () => (
|
||||
<svg className="hm-pfeil" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M5 12h14M13 6l6 6-6 6" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Handset = () => (
|
||||
<svg className="hm-tel__symbol" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="6.5" y="2.5" width="11" height="19" rx="3" fill="none" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M10.5 18.5h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── Messen und Klickpunkte ────────────────────────────────────────────────────
|
||||
|
||||
type Rechtecke = Record<string, PunktRechteck>;
|
||||
|
||||
const gleich = (a: Rechtecke, b: Rechtecke) =>
|
||||
Object.keys(b).length === Object.keys(a).length &&
|
||||
Object.entries(b).every(([id, r]) => {
|
||||
const alt = a[id];
|
||||
return (
|
||||
alt && Math.abs(alt.x - r.x) < 0.5 && Math.abs(alt.y - r.y) < 0.5 && Math.abs(alt.b - r.b) < 0.5 && Math.abs(alt.h - r.h) < 0.5
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Misst die Zonen-Rechtecke relativ zur Fläche in Designpunkten. getBoundingClientRect liefert Bildschirmpunkte;
|
||||
* der Maßstab des Rahmens (transform: scale) ergibt sich aus Bildschirmbreite / Layoutbreite der Fläche.
|
||||
* Ohne Messung (jsdom, ausgeblendet) gelten die festen Ersatz-Rechtecke.
|
||||
*/
|
||||
const useGemessen = (ref: RefObject<HTMLElement | null>, variante: DemoVariante) => {
|
||||
const [rechtecke, setRechtecke] = useState<Rechtecke>(ZONEN_LAYOUT[variante]);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
let aktiv = true;
|
||||
const messe = () => {
|
||||
if (!aktiv) return;
|
||||
const fl = el.getBoundingClientRect();
|
||||
if (!fl.width || !el.offsetWidth) return;
|
||||
const k = fl.width / el.offsetWidth;
|
||||
const neu: Rechtecke = {};
|
||||
el.querySelectorAll<HTMLElement>("[data-zone]").forEach((z) => {
|
||||
const r = z.getBoundingClientRect();
|
||||
neu[z.dataset.zone as string] = {
|
||||
x: (r.left - fl.left) / k,
|
||||
y: (r.top - fl.top) / k,
|
||||
b: r.width / k,
|
||||
h: r.height / k,
|
||||
};
|
||||
});
|
||||
if (Object.keys(neu).length === 0) return;
|
||||
setRechtecke((alt) => (gleich(alt, neu) ? alt : neu));
|
||||
};
|
||||
messe();
|
||||
const beobachter = typeof ResizeObserver === "function" ? new ResizeObserver(messe) : null;
|
||||
beobachter?.observe(el);
|
||||
// Die Schrift lädt später und verschiebt Zeilenumbrüche: dann neu messen
|
||||
document.fonts?.ready.then(messe).catch(() => {});
|
||||
window.addEventListener("resize", messe);
|
||||
return () => {
|
||||
aktiv = false;
|
||||
beobachter?.disconnect();
|
||||
window.removeEventListener("resize", messe);
|
||||
};
|
||||
}, [ref, variante]);
|
||||
return rechtecke;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wie viele Punkte eine Zone bekommt. Die Skala der Heatmap sättigt schon bei zwei, drei überlagerten Flecken (rot);
|
||||
* damit die Farbe die Rangfolge zeigt, steigt die Dichte mit dem Quadrat des Klickanteils (Tageskarte = 1):
|
||||
* nur die meistgeklickte Stelle wird durchgehend rot, Menü und Telefon bleiben gelb bis grün.
|
||||
* Große Zonen bekommen mehr Punkte, damit ihre Fläche nicht leer wirkt.
|
||||
*/
|
||||
const punktAnzahl = (zone: Zone, r: PunktRechteck, variante: DemoVariante) => {
|
||||
const anteil = zone.klicks / TOP_ZONE.klicks;
|
||||
// Fläche eines Flecks in Designpunkten (Leinwand = halbe Fläche, der Radius gilt in Leinwandpunkten)
|
||||
const fleck = Math.PI * (2 * LEINWAND[variante].radius) ** 2;
|
||||
const flecke = Math.max(r.b * r.h, 2 * fleck) / (2 * fleck);
|
||||
const obergrenze = Math.max(1, Math.round(zone.klicks / KLICKS_JE_PUNKT[variante]));
|
||||
return Math.min(obergrenze, Math.max(1, Math.round(anteil ** 2 * 8 * flecke)));
|
||||
};
|
||||
|
||||
/** Alle Klickpunkte (Beispieldaten, feste Saat) je Zone, relativ zur Fläche. 85 % in der Zone, 15 % als Streuung am Rand. */
|
||||
const erzeugePunkte = (rechtecke: Rechtecke, variante: DemoVariante): Record<string, HeatPunkt[]> => {
|
||||
const { breite, hoehe } = FLAECHE_GROESSE[variante];
|
||||
const aus: Record<string, HeatPunkt[]> = {};
|
||||
ZONEN.forEach((zone, i) => {
|
||||
const r = rechtecke[zone.id] ?? ZONEN_LAYOUT[variante][zone.id];
|
||||
const rand = Math.min(r.b, r.h) * 0.3;
|
||||
const zufallsfolge = zufall(1000 + i * 17 + (variante === "handy" ? 5 : 0));
|
||||
const anzahl = punktAnzahl(zone, r, variante);
|
||||
const kern = Math.round(anzahl * 0.85);
|
||||
const innen = punkteInZone({ x: r.x / breite, y: r.y / hoehe, b: r.b / breite, h: r.h / hoehe }, kern, zufallsfolge);
|
||||
const streu = punkteInZone(
|
||||
{
|
||||
x: (r.x - rand) / breite,
|
||||
y: (r.y - rand) / hoehe,
|
||||
b: (r.b + 2 * rand) / breite,
|
||||
h: (r.h + 2 * rand) / hoehe,
|
||||
},
|
||||
anzahl - kern,
|
||||
zufallsfolge,
|
||||
);
|
||||
// mischen, damit jede Teilmenge (die ersten n Punkte) gleichmäßig über die Zone verteilt ist
|
||||
const alle = [...innen, ...streu].map((p) => ({
|
||||
x: Math.min(1, Math.max(0, p.x)),
|
||||
y: Math.min(1, Math.max(0, p.y)),
|
||||
}));
|
||||
for (let n = alle.length - 1; n > 0; n--) {
|
||||
const m = Math.floor(zufallsfolge() * (n + 1));
|
||||
[alle[n], alle[m]] = [alle[m], alle[n]];
|
||||
}
|
||||
aus[zone.id] = alle;
|
||||
});
|
||||
return aus;
|
||||
};
|
||||
|
||||
const KLICKS_JE_PUNKT = { desktop: 4, handy: 7 } as const;
|
||||
|
||||
const LEINWAND = { desktop: { b: 756, h: 447, radius: 19 }, handy: { b: 201, h: 437, radius: 9 } } as const;
|
||||
|
||||
// ── Seite ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const HeatmapSeite = ({ zustand = HEATMAP_START, variante }: Props) => {
|
||||
const wurzel = useRef<HTMLDivElement>(null);
|
||||
const leinwand = useRef<HTMLCanvasElement>(null);
|
||||
const fein = useFeinerZeiger();
|
||||
const rechtecke = useGemessen(wurzel, variante);
|
||||
const punkte = useMemo(() => erzeugePunkte(rechtecke, variante), [rechtecke, variante]);
|
||||
const { fuellung, statisch } = zustand;
|
||||
|
||||
// Neu zeichnen nur, wenn sich die sichtbaren Punkte ändern (neuer Klick, neue Messung), nicht je Bild
|
||||
useEffect(() => {
|
||||
const c = leinwand.current;
|
||||
if (!c) return;
|
||||
const sichtbar: HeatPunkt[] = [];
|
||||
for (const z of ZONEN) {
|
||||
const alle = punkte[z.id];
|
||||
const anzahl = Math.round((fuellung[z.id] ?? 0) * alle.length);
|
||||
if (anzahl > 0) sichtbar.push(...alle.slice(0, anzahl));
|
||||
}
|
||||
zeichneHeatmap(c, sichtbar, LEINWAND[variante].radius);
|
||||
}, [punkte, fuellung, variante]);
|
||||
|
||||
const zoneUnter = (e: MouseEvent) => {
|
||||
const z = (e.target as Element).closest?.("[data-zone]");
|
||||
return z?.getAttribute("data-zone") ?? null;
|
||||
};
|
||||
const bewegt = (e: MouseEvent) => {
|
||||
if (!zustand.maus) zustand.setzeMaus(true);
|
||||
const id = zoneUnter(e);
|
||||
if (id !== zustand.auswahl) zustand.waehle(id);
|
||||
};
|
||||
const verlassen = () => {
|
||||
zustand.setzeMaus(false);
|
||||
zustand.waehle(null);
|
||||
};
|
||||
const getippt = (e: MouseEvent) => {
|
||||
const id = zoneUnter(e);
|
||||
// Maus: wählen. Finger: wählen, nochmal tippen hebt die Auswahl auf.
|
||||
zustand.waehle(!fein && id !== null && id === zustand.auswahl ? null : id);
|
||||
};
|
||||
|
||||
const gewaehlt = zoneNach(zustand.auswahl);
|
||||
const r = gewaehlt ? rechtecke[gewaehlt.id] : undefined;
|
||||
const { breite } = FLAECHE_GROESSE[variante];
|
||||
const zeiger = zustand.zeiger[variante];
|
||||
// Schildchen über der Zone; im Kopf der Seite und auf dem Handy in der oberen Hälfte darunter (sonst verdeckt es die Überschrift)
|
||||
// Große Zonen (das Routenbild) tragen das Schildchen im Inneren, sonst würde es Nachbarn verdecken.
|
||||
const innen = r ? r.h >= (variante === "desktop" ? 250 : 140) : false;
|
||||
const unten = !innen && r ? (variante === "desktop" ? r.y < 80 : r.y < 440) : false;
|
||||
const lage = innen ? "innen" : unten ? "unter" : "ueber";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wurzel}
|
||||
className={`hm hm-seite hm-${variante}`}
|
||||
data-variante={variante}
|
||||
data-phase={zustand.phase}
|
||||
data-laeuft={zustand.laeuft ? "ja" : "nein"}
|
||||
aria-hidden="true"
|
||||
onMouseMove={fein ? bewegt : undefined}
|
||||
onMouseLeave={fein ? verlassen : undefined}
|
||||
onClick={getippt}
|
||||
>
|
||||
{variante === "desktop" ? <Desktop /> : <Handy />}
|
||||
|
||||
<canvas
|
||||
ref={leinwand}
|
||||
className="hm-heat"
|
||||
width={LEINWAND[variante].b}
|
||||
height={LEINWAND[variante].h}
|
||||
data-anteil={zustand.punkte}
|
||||
/>
|
||||
|
||||
{gewaehlt && r ? (
|
||||
<>
|
||||
<div
|
||||
key={`rahmen-${gewaehlt.id}`}
|
||||
className="hm-rahmen"
|
||||
style={{ left: r.x, top: r.y, width: r.b, height: r.h }}
|
||||
/>
|
||||
<div
|
||||
key={`schild-${gewaehlt.id}`}
|
||||
className="hm-schild"
|
||||
data-lage={lage}
|
||||
style={{
|
||||
top: innen ? r.y + (variante === "desktop" ? 28 : 10) : unten ? r.y + r.h + 14 : r.y - 14,
|
||||
...(r.x + r.b / 2 > breite * 0.55
|
||||
? { right: breite - (r.x + r.b) + (innen ? (variante === "desktop" ? 28 : 10) : 0) }
|
||||
: { left: r.x + (innen ? (variante === "desktop" ? 28 : 10) : 0) }),
|
||||
}}
|
||||
>
|
||||
{schildText(gewaehlt)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{statisch ? null : (
|
||||
<DemoZeiger
|
||||
x={zeiger.x}
|
||||
y={zeiger.y}
|
||||
klick={zeiger.klick}
|
||||
sichtbar={zeiger.sichtbar}
|
||||
art={variante === "handy" ? "finger" : "maus"}
|
||||
groesse={variante === "handy" ? 1 : 1.9}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Desktop-Fassung (1512 x 893) ─────────────────────────────────────────────
|
||||
|
||||
const Desktop = () => (
|
||||
<>
|
||||
<div className="hm-kopf">
|
||||
<div className="hm-logo" data-zone="logo">
|
||||
<LogoZeichen />
|
||||
<span className="hm-logo__text">
|
||||
<span className="hm-logo__klein">Boulderhalle</span>
|
||||
<span className="hm-logo__gross">Überhang</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="hm-nav">
|
||||
<span className="hm-nav__punkt" data-zone="nav-routen">
|
||||
Routen
|
||||
</span>
|
||||
<span className="hm-nav__punkt" data-zone="nav-kurse">
|
||||
Kurse
|
||||
</span>
|
||||
<span className="hm-nav__punkt">Preise</span>
|
||||
<span className="hm-nav__punkt">Öffnungszeiten</span>
|
||||
</div>
|
||||
<div className="hm-tel" data-zone="telefon">
|
||||
<Handset />
|
||||
<span>0221 4710 142</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hm-held">
|
||||
<div className="hm-held__vor">
|
||||
<span className="hm-held__punkt" />
|
||||
Frisch geschraubt
|
||||
</div>
|
||||
<div className="hm-held__titel">
|
||||
<span>
|
||||
Heute <em>14</em>
|
||||
</span>
|
||||
<span>neue Boulder</span>
|
||||
</div>
|
||||
<div className="hm-knopf" data-zone="tageskarte">
|
||||
<span>Tageskarte kaufen</span>
|
||||
<Pfeil />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hm-wand" data-zone="routenbild">
|
||||
<Routenwand b={640} h={260} />
|
||||
</div>
|
||||
|
||||
{/* Öffnungszeiten liegen unter der Wand und links vom iPhone der Bühne (das deckt x > 1158 / y > 419 der Fläche) */}
|
||||
<div className="hm-karte hm-oeffnung" data-zone="oeffnungszeiten">
|
||||
<div className="hm-karte__titel">Öffnungszeiten</div>
|
||||
<div className="hm-preise">
|
||||
<div>
|
||||
<span>Mo–Fr</span>
|
||||
<b>10–23 Uhr</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Sa, So</span>
|
||||
<b>9–21 Uhr</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hm-karten">
|
||||
<div className="hm-karte hm-karte--live" data-zone="auslastung">
|
||||
<div className="hm-karte__kopf">
|
||||
<span className="hm-live" />
|
||||
Live-Auslastung
|
||||
</div>
|
||||
<div className="hm-zahl">
|
||||
42<span>%</span>
|
||||
</div>
|
||||
<div className="hm-balken">
|
||||
<span style={{ width: "42%" }} />
|
||||
</div>
|
||||
<div className="hm-karte__text">Gerade viel Platz an der Wand.</div>
|
||||
</div>
|
||||
|
||||
<div className="hm-karte hm-karte--kurs" data-zone="schnupperkurs">
|
||||
<div className="hm-marke hm-marke--cyan">Für Einsteiger</div>
|
||||
<div className="hm-karte__titel">Schnupperkurs</div>
|
||||
<div className="hm-karte__text">Samstag, 10 Uhr, 90 Minuten. Leihschuhe sind dabei.</div>
|
||||
</div>
|
||||
|
||||
<div className="hm-karte" data-zone="preise">
|
||||
<div className="hm-karte__titel">Preise</div>
|
||||
<div className="hm-preise">
|
||||
<div>
|
||||
<span>Tageskarte</span>
|
||||
<b>14 €</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>10er-Karte</span>
|
||||
<b>120 €</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Monatskarte</span>
|
||||
<b>59 €</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// ── Handy-Fassung (402 x 874, oben 59 und unten 34 frei) ──────────────────────
|
||||
|
||||
const Handy = () => (
|
||||
<>
|
||||
<div className="hm-h-kopf">
|
||||
<div className="hm-logo" data-zone="logo">
|
||||
<LogoZeichen />
|
||||
<span className="hm-logo__text">
|
||||
<span className="hm-logo__klein">Boulderhalle</span>
|
||||
<span className="hm-logo__gross">Überhang</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="hm-tel" data-zone="telefon">
|
||||
<Handset />
|
||||
<span>0221 4710 142</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hm-h-nav">
|
||||
<span className="hm-nav__punkt" data-zone="nav-routen">
|
||||
Routen
|
||||
</span>
|
||||
<span className="hm-nav__punkt" data-zone="nav-kurse">
|
||||
Kurse
|
||||
</span>
|
||||
<span className="hm-nav__punkt">Preise</span>
|
||||
<span className="hm-nav__punkt">Zeiten</span>
|
||||
</div>
|
||||
|
||||
<div className="hm-h-held">
|
||||
<div className="hm-held__vor">
|
||||
<span className="hm-held__punkt" />
|
||||
Frisch geschraubt
|
||||
</div>
|
||||
<div className="hm-held__titel">
|
||||
<span>
|
||||
Heute <em>14</em>
|
||||
</span>
|
||||
<span>neue Boulder</span>
|
||||
</div>
|
||||
<div className="hm-knopf" data-zone="tageskarte">
|
||||
<span>Tageskarte kaufen</span>
|
||||
<Pfeil />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hm-h-live hm-karte hm-karte--live" data-zone="auslastung">
|
||||
<div className="hm-zahl">
|
||||
42<span>%</span>
|
||||
</div>
|
||||
<div className="hm-h-live__rechts">
|
||||
<div className="hm-karte__kopf">
|
||||
<span className="hm-live" />
|
||||
Live-Auslastung
|
||||
</div>
|
||||
<div className="hm-balken">
|
||||
<span style={{ width: "42%" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hm-wand" data-zone="routenbild">
|
||||
<Routenwand b={362} h={150} klein />
|
||||
</div>
|
||||
|
||||
<div className="hm-karte hm-karte--kurs hm-h-kurs" data-zone="schnupperkurs">
|
||||
<div className="hm-karte__titel">Schnupperkurs</div>
|
||||
<div className="hm-karte__text">Sa 10 Uhr, Leihschuhe dabei</div>
|
||||
</div>
|
||||
|
||||
<div className="hm-h-reihe">
|
||||
<div className="hm-karte" data-zone="preise">
|
||||
<div className="hm-karte__titel">Preise</div>
|
||||
<div className="hm-preise">
|
||||
<div>
|
||||
<span>Tageskarte</span>
|
||||
<b>14 €</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Monat</span>
|
||||
<b>59 €</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hm-karte" data-zone="oeffnungszeiten">
|
||||
<div className="hm-karte__titel">Geöffnet</div>
|
||||
<div className="hm-preise">
|
||||
<div>
|
||||
<span>Mo–Fr</span>
|
||||
<b>10–23</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Sa, So</span>
|
||||
<b>9–21</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default HeatmapSeite;
|
||||
export { HeatmapSeite };
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Ablauf der Demo „Website-Analyse“: ein simulierter Zeiger klickt nacheinander Stellen der Beispiel-Website an,
|
||||
* die Heatmap wird mit jedem Klick dichter, ist nach ca. 10 s voll, bleibt ca. 6 s stehen und blendet aus.
|
||||
* Sobald die Maus über der Website ist (oder eine Zone gewählt wird), stoppt der Ablauf und die Heatmap ist voll.
|
||||
* Diese Datei wird sofort geladen und darf kein CSS importieren. Es laufen nur setTimeout-Ketten, kein rAF,
|
||||
* und nichts, solange `laeuft` false ist.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { DemoVariante } from "../gemeinsam";
|
||||
import { SUMME_KLICKS, TOP_ZONE, ZONEN, zonenPunkt, zoneNach } from "./zonen";
|
||||
|
||||
export type HeatmapPhase = "aufbau" | "voll" | "ausblenden";
|
||||
|
||||
export type HeatmapZeiger = { x: number; y: number; klick: number; sichtbar: boolean };
|
||||
|
||||
export type HeatmapZustand = {
|
||||
phase: HeatmapPhase;
|
||||
/** Wie viele Klickpunkte je Zone sichtbar sind: 0 (keine) bis 1 (alle). */
|
||||
fuellung: Readonly<Record<string, number>>;
|
||||
/** Anzahl sichtbarer Klickpunkte insgesamt (von 1.284) und ihr Anteil 0 bis 1. */
|
||||
punkte: number;
|
||||
anteil: number;
|
||||
/** Simulierter Zeiger je Fassung in Designpunkten der jeweiligen Fläche. */
|
||||
zeiger: Record<DemoVariante, HeatmapZeiger>;
|
||||
/** Gewählte Zone (Maus, Tippen, Rangliste) oder im statischen Zustand die meistgeklickte; sonst null. */
|
||||
auswahl: string | null;
|
||||
/** true, solange die Maus über der Website ist. */
|
||||
maus: boolean;
|
||||
/** Läuft der simulierte Ablauf gerade (wirklich, nicht pausiert)? */
|
||||
laeuft: boolean;
|
||||
statisch: boolean;
|
||||
waehle: (id: string | null) => void;
|
||||
setzeMaus: (an: boolean) => void;
|
||||
};
|
||||
|
||||
// ── Drehbuch ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Reihenfolge der angeklickten Zonen: häufig geklickte öfter (Tageskarte 3 x, Live-Auslastung und Routenbild 2 x). */
|
||||
export const KLICK_FOLGE: readonly string[] = [
|
||||
"tageskarte",
|
||||
"auslastung",
|
||||
"routenbild",
|
||||
"telefon",
|
||||
"tageskarte",
|
||||
"nav-kurse",
|
||||
"preise",
|
||||
"schnupperkurs",
|
||||
"routenbild",
|
||||
"oeffnungszeiten",
|
||||
"auslastung",
|
||||
"nav-routen",
|
||||
"logo",
|
||||
"tageskarte",
|
||||
];
|
||||
|
||||
/** Wo im Rechteck der Zeiger landet (Anteil von Breite und Höhe), damit nicht jeder Klick genau die Mitte trifft. */
|
||||
const ZIEL_VERSATZ: readonly [number, number][] = [
|
||||
[0.5, 0.5],
|
||||
[0.5, 0.35],
|
||||
[0.4, 0.55],
|
||||
[0.5, 0.5],
|
||||
[0.62, 0.45],
|
||||
[0.5, 0.5],
|
||||
[0.45, 0.4],
|
||||
[0.55, 0.5],
|
||||
[0.62, 0.4],
|
||||
[0.5, 0.45],
|
||||
[0.55, 0.6],
|
||||
[0.5, 0.5],
|
||||
[0.5, 0.5],
|
||||
[0.38, 0.55],
|
||||
];
|
||||
|
||||
const DAUER = { start: 450, flug: 560, klick: 130, voll: 6000, aus: 1000 } as const;
|
||||
|
||||
type Schritt =
|
||||
| { art: "start"; dauer: number }
|
||||
| { art: "flug" | "klick"; ziel: number; dauer: number }
|
||||
| { art: "voll" | "aus"; dauer: number };
|
||||
|
||||
const SCHRITTE: readonly Schritt[] = [
|
||||
{ art: "start", dauer: DAUER.start },
|
||||
...KLICK_FOLGE.flatMap((_, ziel): Schritt[] => [
|
||||
{ art: "flug", ziel, dauer: DAUER.flug },
|
||||
{ art: "klick", ziel, dauer: DAUER.klick },
|
||||
]),
|
||||
{ art: "voll", dauer: DAUER.voll },
|
||||
{ art: "aus", dauer: DAUER.aus },
|
||||
];
|
||||
const VOLL_START = SCHRITTE.findIndex((s) => s.art === "voll");
|
||||
|
||||
/** Dauer des Aufbaus bis „voll“ in Millisekunden (für Tests und Doku). */
|
||||
export const AUFBAU_DAUER = SCHRITTE.slice(0, VOLL_START).reduce((summe, s) => summe + s.dauer, 0);
|
||||
|
||||
// ── Füllung der Heatmap nach jedem Klick ───────────────────────────────────
|
||||
|
||||
const KEINE: Readonly<Record<string, number>> = Object.freeze(Object.fromEntries(ZONEN.map((z) => [z.id, 0])));
|
||||
const ALLE: Readonly<Record<string, number>> = Object.freeze(Object.fromEntries(ZONEN.map((z) => [z.id, 1])));
|
||||
|
||||
/**
|
||||
* Füllstand aller Zonen nach dem n-ten Klick. Jeder Besuch einer Zone bringt einen Schub Punkte (k von n Besuchen);
|
||||
* dazu streut jeder Klick ein paar Punkte in zwei andere Zonen, so wächst die Heatmap nicht Zone für Zone.
|
||||
* Nach dem letzten Klick ist alles voll. Feste Saat, also bei jedem Durchlauf gleich.
|
||||
*/
|
||||
const VERLAUF: readonly Readonly<Record<string, number>>[] = (() => {
|
||||
const besuche: Record<string, number> = {};
|
||||
for (const id of KLICK_FOLGE) besuche[id] = (besuche[id] ?? 0) + 1;
|
||||
const gezaehlt: Record<string, number> = {};
|
||||
const stand: Record<string, number> = Object.fromEntries(ZONEN.map((z) => [z.id, 0]));
|
||||
const ids = ZONEN.map((z) => z.id);
|
||||
let a = 7;
|
||||
const naechste = () => {
|
||||
a = (a * 1103515245 + 12345) % 2147483648;
|
||||
return a / 2147483648;
|
||||
};
|
||||
return KLICK_FOLGE.map((id, i) => {
|
||||
gezaehlt[id] = (gezaehlt[id] ?? 0) + 1;
|
||||
stand[id] = Math.max(stand[id], gezaehlt[id] / besuche[id]);
|
||||
for (let s = 0; s < 2; s++) {
|
||||
const fremd = ids[Math.floor(naechste() * ids.length)];
|
||||
// Streuung nur in Zonen, die noch nicht fertig sind, und höchstens bis knapp über die Hälfte
|
||||
if (fremd !== id && gezaehlt[fremd] !== besuche[fremd]) stand[fremd] = Math.min(0.55, stand[fremd] + 0.07);
|
||||
}
|
||||
if (i === KLICK_FOLGE.length - 1) return ALLE;
|
||||
return Object.freeze(
|
||||
Object.fromEntries(Object.entries(stand).map(([k, v]) => [k, Math.round(v * 100) / 100])),
|
||||
) as Readonly<Record<string, number>>;
|
||||
});
|
||||
})();
|
||||
|
||||
const zaehlePunkte = (fuellung: Readonly<Record<string, number>>) =>
|
||||
ZONEN.reduce((summe, z) => summe + Math.round((fuellung[z.id] ?? 0) * z.klicks), 0);
|
||||
|
||||
// ── Zeigerziele ──────────────────────────────────────────────────────────────
|
||||
|
||||
const START_POSITION: Record<DemoVariante, { x: number; y: number }> = {
|
||||
desktop: { x: 1180, y: 760 },
|
||||
handy: { x: 300, y: 700 },
|
||||
};
|
||||
|
||||
const ziele = (variante: DemoVariante) =>
|
||||
KLICK_FOLGE.map((id, i) => zonenPunkt(variante, id, ZIEL_VERSATZ[i][0], ZIEL_VERSATZ[i][1]));
|
||||
|
||||
const ZIELE: Record<DemoVariante, { x: number; y: number }[]> = { desktop: ziele("desktop"), handy: ziele("handy") };
|
||||
|
||||
// ── Ableitung aus dem Schritt ──────────────────────────────────────────────────
|
||||
|
||||
const aus = (schritt: number) => SCHRITTE[Math.min(schritt, SCHRITTE.length - 1)];
|
||||
|
||||
const zeigerFuer = (variante: DemoVariante, schritt: number, aktiv: boolean): HeatmapZeiger => {
|
||||
const s = aus(schritt);
|
||||
if (s.art === "start") return { ...START_POSITION[variante], klick: 0, sichtbar: aktiv };
|
||||
if (s.art === "flug") {
|
||||
const p = ZIELE[variante][s.ziel];
|
||||
return { ...p, klick: s.ziel, sichtbar: aktiv };
|
||||
}
|
||||
if (s.art === "klick") {
|
||||
const p = ZIELE[variante][s.ziel];
|
||||
return { ...p, klick: s.ziel + 1, sichtbar: aktiv };
|
||||
}
|
||||
// „voll“ und „ausblenden“: der Zeiger bleibt am letzten Ziel, wird aber unsichtbar
|
||||
const letzte = ZIELE[variante][KLICK_FOLGE.length - 1];
|
||||
return { ...letzte, klick: KLICK_FOLGE.length, sichtbar: false };
|
||||
};
|
||||
|
||||
const fuellungFuer = (schritt: number): Readonly<Record<string, number>> => {
|
||||
const s = aus(schritt);
|
||||
if (s.art === "start") return KEINE;
|
||||
if (s.art === "flug") return s.ziel === 0 ? KEINE : VERLAUF[s.ziel - 1];
|
||||
if (s.art === "klick") return VERLAUF[s.ziel];
|
||||
return ALLE;
|
||||
};
|
||||
|
||||
const phaseFuer = (schritt: number): HeatmapPhase => {
|
||||
const art = aus(schritt).art;
|
||||
return art === "voll" ? "voll" : art === "aus" ? "ausblenden" : "aufbau";
|
||||
};
|
||||
|
||||
/** Anfangszustand (leere Heatmap, nichts gewählt, Auswahl und Maus ohne Wirkung): damit zeigt die Bühne die Website sofort,
|
||||
* bevor der Taktgeber geladen ist. */
|
||||
export const HEATMAP_START: HeatmapZustand = {
|
||||
phase: "aufbau",
|
||||
fuellung: KEINE,
|
||||
punkte: 0,
|
||||
anteil: 0,
|
||||
zeiger: { desktop: zeigerFuer("desktop", 0, false), handy: zeigerFuer("handy", 0, false) },
|
||||
auswahl: null,
|
||||
maus: false,
|
||||
laeuft: false,
|
||||
statisch: false,
|
||||
waehle: () => {},
|
||||
setzeMaus: () => {},
|
||||
};
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Zustand der Heatmap-Demo.
|
||||
* laeuft = der Ablauf darf abspielen (Station aktiv, im Bild, Tab sichtbar, Bewegung erlaubt).
|
||||
* statisch = keine Bewegung erlaubt: volle Heatmap, kein Zeiger, Auswahl = meistgeklickte Zone.
|
||||
* Hört `laeuft` auf, beginnt der Ablauf beim nächsten Mal von vorn (leere Heatmap).
|
||||
*/
|
||||
export const useHeatmapAblauf = (laeuft: boolean, statisch: boolean): HeatmapZustand => {
|
||||
const [schritt, setSchritt] = useState(0);
|
||||
const [maus, setMaus] = useState(false);
|
||||
const [gewaehlt, setGewaehlt] = useState<string | null>(null);
|
||||
const interaktiv = maus || gewaehlt !== null;
|
||||
const spielt = laeuft && !statisch && !interaktiv;
|
||||
|
||||
useEffect(() => {
|
||||
if (!laeuft || statisch) {
|
||||
setSchritt((s) => (s === 0 ? s : 0));
|
||||
return;
|
||||
}
|
||||
if (interaktiv) return;
|
||||
const t = window.setTimeout(
|
||||
() => setSchritt((s) => (s >= SCHRITTE.length - 1 ? 0 : s + 1)),
|
||||
aus(schritt).dauer,
|
||||
);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [laeuft, statisch, interaktiv, schritt]);
|
||||
|
||||
const waehle = useCallback((id: string | null) => {
|
||||
const gueltig = id !== null && zoneNach(id) ? id : null;
|
||||
setGewaehlt(gueltig);
|
||||
// Wer etwas wählt, sieht die volle Heatmap; nach dem Loslassen geht es von dort aus weiter (nicht vom halben Aufbau)
|
||||
if (gueltig !== null) setSchritt((s) => (phaseFuer(s) === "aufbau" ? VOLL_START : s));
|
||||
}, []);
|
||||
|
||||
const setzeMaus = useCallback((an: boolean) => {
|
||||
setMaus(an);
|
||||
if (an) setSchritt((s) => (phaseFuer(s) === "aufbau" ? VOLL_START : s));
|
||||
}, []);
|
||||
|
||||
return useMemo<HeatmapZustand>(() => {
|
||||
const voll = statisch || interaktiv;
|
||||
const fuellung = voll ? ALLE : fuellungFuer(schritt);
|
||||
const punkte = voll ? SUMME_KLICKS : zaehlePunkte(fuellung);
|
||||
const zeigerAktiv = spielt && phaseFuer(schritt) === "aufbau";
|
||||
return {
|
||||
phase: voll ? "voll" : phaseFuer(schritt),
|
||||
fuellung,
|
||||
punkte,
|
||||
anteil: punkte / SUMME_KLICKS,
|
||||
zeiger: {
|
||||
desktop: zeigerFuer("desktop", schritt, zeigerAktiv),
|
||||
handy: zeigerFuer("handy", schritt, zeigerAktiv),
|
||||
},
|
||||
auswahl: gewaehlt ?? (statisch ? TOP_ZONE.id : null),
|
||||
maus,
|
||||
laeuft: spielt,
|
||||
statisch,
|
||||
waehle,
|
||||
setzeMaus,
|
||||
};
|
||||
}, [schritt, statisch, interaktiv, spielt, gewaehlt, maus, waehle, setzeMaus]);
|
||||
};
|
||||
@@ -0,0 +1,873 @@
|
||||
/*
|
||||
* Demo „Website-Analyse“: die Beispiel-Website „Boulderhalle Überhang“ (.hm-*) und die Diagnose darunter (.hmd-*).
|
||||
* Die Website wird in Designpunkten gesetzt (Desktop 1512 x 893, Handy 402 x 874) und vom Geräterahmen verkleinert,
|
||||
* darum große Schrift und große Flächen. Alle Maße in px = Designpunkte.
|
||||
* Bewegung nur transform/opacity hinter prefers-reduced-motion: no-preference, Hover nur bei feinem Zeiger.
|
||||
*/
|
||||
|
||||
/* ── Grundlage der Website ───────────────────────────────────────────────── */
|
||||
|
||||
.hm {
|
||||
--hm-bg: #141414;
|
||||
--hm-tafel: #1f1f1f;
|
||||
--hm-tafel-2: #272727;
|
||||
--hm-linie: #2c2c2c;
|
||||
--hm-kreide: #f3efe6;
|
||||
--hm-grau: #aaa59b;
|
||||
--hm-lime: #c6f432;
|
||||
--hm-orange: #ff7a1a;
|
||||
--hm-pink: #ff4f9a;
|
||||
--hm-cyan: #2ad1ff;
|
||||
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--hm-bg);
|
||||
color: var(--hm-kreide);
|
||||
font-family: var(--font-ui, "Instrument Sans", system-ui, sans-serif);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
letter-spacing: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
.hm *,
|
||||
.hm *::before,
|
||||
.hm *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
.hm em {
|
||||
font-style: normal;
|
||||
}
|
||||
.hm svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.hm-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.hm-logo__zeichen {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex: none;
|
||||
}
|
||||
.hm-logo__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.hm-logo__klein {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--hm-grau);
|
||||
line-height: 1;
|
||||
}
|
||||
.hm-logo__gross {
|
||||
font-size: 38px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Telefonnummer */
|
||||
.hm-tel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
height: 64px;
|
||||
padding: 0 26px 0 22px;
|
||||
border: 2px solid #3a3a3a;
|
||||
border-radius: 32px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hm-tel__symbol {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--hm-lime);
|
||||
}
|
||||
|
||||
/* Navigationspunkte */
|
||||
.hm-nav__punkt {
|
||||
display: inline-block;
|
||||
padding: 10px 22px;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hauptknopf */
|
||||
.hm-knopf {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
height: 88px;
|
||||
padding: 0 40px 0 48px;
|
||||
border-radius: 44px;
|
||||
background: var(--hm-lime);
|
||||
color: #141414;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hm-pfeil {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
/* Karten */
|
||||
.hm-karte {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 28px 26px;
|
||||
border-radius: 28px;
|
||||
background: var(--hm-tafel);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hm-karte__titel {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.hm-karte__text {
|
||||
font-size: 23px;
|
||||
line-height: 1.3;
|
||||
color: var(--hm-grau);
|
||||
}
|
||||
.hm-karte__kopf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 25px;
|
||||
font-weight: 600;
|
||||
color: var(--hm-kreide);
|
||||
}
|
||||
.hm-live {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--hm-lime);
|
||||
flex: none;
|
||||
}
|
||||
.hm-zahl {
|
||||
font-size: 84px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 0.95;
|
||||
color: var(--hm-lime);
|
||||
}
|
||||
.hm-zahl span {
|
||||
margin-left: 4px;
|
||||
font-size: 0.6em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.hm-balken {
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
background: #343434;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hm-balken span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 11px;
|
||||
background: var(--hm-lime);
|
||||
}
|
||||
.hm-marke {
|
||||
align-self: flex-start;
|
||||
padding: 6px 18px;
|
||||
border-radius: 999px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.hm-marke--cyan {
|
||||
background: var(--hm-cyan);
|
||||
color: #0b2530;
|
||||
}
|
||||
.hm-marke--lime {
|
||||
background: #2a3312;
|
||||
color: var(--hm-lime);
|
||||
}
|
||||
.hm-preise {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.hm-preise > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
font-size: 25px;
|
||||
}
|
||||
.hm-preise span {
|
||||
color: var(--hm-grau);
|
||||
}
|
||||
.hm-preise b {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Wand */
|
||||
.hm-wand {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 28px;
|
||||
background: #2a2a2e;
|
||||
}
|
||||
.hm-wand__svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ── Desktop 1512 x 893 ──────────────────────────────────────────────────── */
|
||||
|
||||
.hm-desktop .hm-kopf {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 120px;
|
||||
padding: 0 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 2px solid var(--hm-linie);
|
||||
}
|
||||
.hm-desktop .hm-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: 80px;
|
||||
}
|
||||
.hm-desktop .hm-held {
|
||||
position: absolute;
|
||||
left: 64px;
|
||||
top: 150px;
|
||||
width: 720px;
|
||||
}
|
||||
.hm-held__vor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: var(--hm-kreide);
|
||||
}
|
||||
.hm-held__punkt {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--hm-orange);
|
||||
}
|
||||
.hm-held__titel {
|
||||
margin-top: 16px;
|
||||
font-size: 104px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 0.98;
|
||||
}
|
||||
.hm-held__titel span {
|
||||
display: block;
|
||||
}
|
||||
.hm-held__titel em {
|
||||
color: var(--hm-lime);
|
||||
}
|
||||
.hm-held__text {
|
||||
margin-top: 22px;
|
||||
margin-bottom: 28px;
|
||||
font-size: 30px;
|
||||
font-weight: 500;
|
||||
color: var(--hm-grau);
|
||||
}
|
||||
.hm-desktop .hm-knopf {
|
||||
margin-top: 80px;
|
||||
}
|
||||
/* Das iPhone der Bühne deckt die Fläche rechts unten ab (x > 1158, y > 419): Wand und Öffnungszeiten rechts oben,
|
||||
die drei Karten unten reichen nur bis x 1138. So bleibt jede Klickzone sichtbar und hoverbar. */
|
||||
.hm-desktop .hm-wand {
|
||||
position: absolute;
|
||||
left: 808px;
|
||||
top: 144px;
|
||||
width: 640px;
|
||||
height: 260px;
|
||||
}
|
||||
.hm-desktop .hm-oeffnung {
|
||||
position: absolute;
|
||||
left: 808px;
|
||||
top: 424px;
|
||||
width: 330px;
|
||||
height: 178px;
|
||||
padding: 24px 26px;
|
||||
gap: 14px;
|
||||
}
|
||||
.hm-desktop .hm-oeffnung .hm-karte__titel {
|
||||
font-size: 34px;
|
||||
}
|
||||
.hm-desktop .hm-karten {
|
||||
position: absolute;
|
||||
left: 64px;
|
||||
top: 620px;
|
||||
width: 1074px;
|
||||
height: 248px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
.hm-desktop .hm-karte--live {
|
||||
gap: 0;
|
||||
}
|
||||
.hm-desktop .hm-karte--live .hm-zahl {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.hm-desktop .hm-karte--live .hm-balken {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.hm-desktop .hm-karte--live .hm-karte__text {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.hm-desktop .hm-karte--kurs {
|
||||
background: #16262c;
|
||||
gap: 12px;
|
||||
}
|
||||
.hm-desktop .hm-karten .hm-karte:nth-child(3) {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* ── Handy 402 x 874 (oben 59 / unten 34 frei) ───────────────────────────── */
|
||||
|
||||
.hm-handy .hm-h-kopf {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 115px;
|
||||
padding: 59px 16px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--hm-bg);
|
||||
}
|
||||
.hm-handy .hm-logo {
|
||||
gap: 10px;
|
||||
}
|
||||
.hm-handy .hm-logo__zeichen {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.hm-handy .hm-logo__klein {
|
||||
font-size: 12px;
|
||||
}
|
||||
.hm-handy .hm-logo__gross {
|
||||
font-size: 23px;
|
||||
}
|
||||
.hm-handy .hm-tel {
|
||||
height: 38px;
|
||||
gap: 7px;
|
||||
padding: 0 12px 0 10px;
|
||||
border-width: 1.5px;
|
||||
border-radius: 19px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.hm-handy .hm-tel__symbol {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.hm-handy .hm-h-nav {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 115px;
|
||||
height: 44px;
|
||||
padding: 0 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1.5px solid var(--hm-linie);
|
||||
background: var(--hm-bg);
|
||||
}
|
||||
.hm-handy .hm-nav__punkt {
|
||||
padding: 8px 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.hm-handy .hm-h-held {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
top: 172px;
|
||||
}
|
||||
.hm-handy .hm-held__vor {
|
||||
font-size: 15px;
|
||||
gap: 8px;
|
||||
}
|
||||
.hm-handy .hm-held__punkt {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
.hm-handy .hm-held__titel {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 44px;
|
||||
line-height: 0.98;
|
||||
}
|
||||
.hm-handy .hm-knopf {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 58px;
|
||||
padding: 0 22px 0 26px;
|
||||
border-radius: 29px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.hm-handy .hm-pfeil {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.hm-handy .hm-karte {
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.hm-handy .hm-karte__titel {
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.hm-handy .hm-karte__text {
|
||||
font-size: 15px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.hm-handy .hm-karte__kopf {
|
||||
font-size: 15px;
|
||||
gap: 8px;
|
||||
}
|
||||
.hm-handy .hm-live {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
.hm-handy .hm-h-live {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
top: 374px;
|
||||
height: 78px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.hm-handy .hm-zahl {
|
||||
font-size: 40px;
|
||||
}
|
||||
.hm-handy .hm-h-live__rechts {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.hm-handy .hm-balken {
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.hm-handy .hm-balken span {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.hm-handy .hm-wand {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 466px;
|
||||
width: 362px;
|
||||
height: 150px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.hm-handy .hm-h-kurs {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 630px;
|
||||
width: 362px;
|
||||
height: 66px;
|
||||
background: #16262c;
|
||||
justify-content: center;
|
||||
}
|
||||
.hm-handy .hm-h-reihe {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 710px;
|
||||
width: 362px;
|
||||
height: 118px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.hm-handy .hm-h-reihe .hm-karte {
|
||||
gap: 6px;
|
||||
}
|
||||
.hm-handy .hm-preise {
|
||||
gap: 4px;
|
||||
}
|
||||
.hm-handy .hm-preise > div {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ── Heatmap, Rahmen, Schildchen ─────────────────────────────────────────── */
|
||||
|
||||
.hm-heat {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.hm[data-phase="ausblenden"] .hm-heat {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.hm-rahmen {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
border: 6px solid #fff;
|
||||
border-radius: 30px;
|
||||
box-shadow:
|
||||
0 0 0 4px #141414,
|
||||
0 0 0 10px rgb(0 0 0 / 0.18);
|
||||
margin: -12px 0 0 -12px;
|
||||
padding: 6px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.hm-handy .hm-rahmen {
|
||||
border-width: 3px;
|
||||
border-radius: 14px;
|
||||
margin: -6px 0 0 -6px;
|
||||
padding: 3px;
|
||||
box-shadow: 0 0 0 2px #141414;
|
||||
}
|
||||
.hm-schild {
|
||||
position: absolute;
|
||||
z-index: 21;
|
||||
pointer-events: none;
|
||||
width: max-content;
|
||||
max-width: 760px;
|
||||
padding: 10px 24px 12px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
color: #141414;
|
||||
font-size: 31px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.01em;
|
||||
box-shadow: 0 6px 24px rgb(0 0 0 / 0.4);
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.hm-schild[data-lage="unter"],
|
||||
.hm-schild[data-lage="innen"] {
|
||||
transform: none;
|
||||
}
|
||||
.hm-handy .hm-schild {
|
||||
max-width: 340px;
|
||||
padding: 7px 12px 8px;
|
||||
border-radius: 9px;
|
||||
font-size: 15px;
|
||||
box-shadow: 0 4px 14px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
/* Der Zeiger gleitet auf dieser Seite etwas schneller als die Standardzeit (Takt: 560 ms Flug) */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.hm .dz-zeiger {
|
||||
transition:
|
||||
transform 520ms cubic-bezier(0.45, 0, 0.2, 1),
|
||||
opacity 300ms ease;
|
||||
}
|
||||
.hm .hm-heat {
|
||||
transition: opacity 900ms ease;
|
||||
}
|
||||
.hm[data-phase="aufbau"] .hm-heat {
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.hm-rahmen,
|
||||
.hm-schild {
|
||||
animation: hm-ein 160ms ease-out both;
|
||||
}
|
||||
.hm[data-laeuft="ja"] .hm-live {
|
||||
animation: hm-puls 1.6s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
@keyframes hm-ein {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes hm-puls {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Diagnose (normaler Seitenmaßstab, Seitentoken) ──────────────────────── */
|
||||
|
||||
.hmd {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
padding: 12px 16px 14px;
|
||||
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;
|
||||
}
|
||||
.hmd *,
|
||||
.hmd *::before,
|
||||
.hmd *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.hmd__kopf {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin: 0 0 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hmd__titel {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
.hmd__quelle {
|
||||
color: var(--fg-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.hmd__summe {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hmd__koerper {
|
||||
/* Platz für fünf Zeilen Satz: die Karte springt nicht, wenn die gewählte Zone wechselt */
|
||||
min-height: 122px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
||||
gap: 8px 18px;
|
||||
align-items: start;
|
||||
}
|
||||
.hmd__liste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.hmd__zeile {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 56px 36px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: calc(100% + 16px);
|
||||
min-height: 26px;
|
||||
padding: 0 8px;
|
||||
margin: 0 -8px;
|
||||
border-radius: 8px;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hmd__zeile:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.hmd__zeile[aria-pressed="true"] {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
.hmd__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.hmd__name > span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hmd__tot {
|
||||
flex: none;
|
||||
padding: 0 7px;
|
||||
border: 1px solid var(--accent-text);
|
||||
border-radius: 999px;
|
||||
color: var(--accent-text);
|
||||
font-size: 14px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hmd__spur {
|
||||
display: block;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hmd__balken {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: var(--line-strong);
|
||||
transform-origin: left center;
|
||||
}
|
||||
.hmd__zeile[aria-pressed="true"] .hmd__balken {
|
||||
background: var(--accent);
|
||||
}
|
||||
.hmd__prozent {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* rechts: Satz zur gewählten Zone, darunter die Farbskala. Die Spalte ist nie höher als die Liste, die Karte springt nicht. */
|
||||
.hmd__rechts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.hmd__legende {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 17px;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.hmd__skala {
|
||||
flex: 0 0 64px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, rgb(0 0 255), rgb(0 255 0) 45%, rgb(255 255 0) 75%, rgb(255 0 0));
|
||||
}
|
||||
.hmd__satz {
|
||||
margin: 0;
|
||||
padding: 2px 0 2px 14px;
|
||||
border-left: 3px solid var(--accent);
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
color: var(--fg);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.hmd__satz[data-tot="ja"] {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
/* kompakt: für die Handy-Karte, schmal (bis 320 px), Satz oben, drei Zeilen */
|
||||
.hmd--kompakt {
|
||||
max-width: 320px;
|
||||
padding: 12px 14px 12px;
|
||||
}
|
||||
.hmd--kompakt .hmd__kopf {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
.hmd--kompakt .hmd__koerper {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.hmd--kompakt .hmd__rechts {
|
||||
display: contents;
|
||||
}
|
||||
.hmd--kompakt .hmd__satz {
|
||||
order: -1;
|
||||
margin-bottom: 2px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.hmd--kompakt .hmd__legende {
|
||||
order: 1;
|
||||
padding-left: 0;
|
||||
}
|
||||
.hmd--kompakt .hmd__zeile {
|
||||
grid-template-columns: minmax(0, 1fr) 48px 36px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.hmd__balken {
|
||||
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.hmd__zeile:hover {
|
||||
background: color-mix(in srgb, var(--fg) 7%, transparent);
|
||||
}
|
||||
.hmd__zeile[aria-pressed="true"]:hover {
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* niedrige Fenster (Laptops mit wenig Bildschirmhöhe): die Diagnose rückt enger zusammen, damit sie unter dem MacBook
|
||||
noch ins Bild passt */
|
||||
@media (max-height: 760px) {
|
||||
.hmd:not(.hmd--kompakt) {
|
||||
padding: 8px 14px 10px;
|
||||
}
|
||||
.hmd:not(.hmd--kompakt) .hmd__kopf {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.hmd:not(.hmd--kompakt) .hmd__zeile {
|
||||
min-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Schmale Bühne (kleine Fenster): die Marke „toter Klick“ wird zum roten Punkt und der Balken kürzer, damit die Namen
|
||||
nicht abgeschnitten werden. Die Bühne (.kp-geraete) ist der Container. */
|
||||
@container (max-width: 729px) {
|
||||
.hmd:not(.hmd--kompakt) .hmd__tot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-text);
|
||||
font-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@container (max-width: 649px) {
|
||||
.hmd:not(.hmd--kompakt) .hmd__zeile {
|
||||
grid-template-columns: minmax(0, 1fr) 20px 36px;
|
||||
}
|
||||
}
|
||||
@media (max-height: 760px) {
|
||||
.hmd:not(.hmd--kompakt) .hmd__koerper {
|
||||
min-height: 102px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Klickzonen der erfundenen Beispiel-Website „Boulderhalle Überhang“ (Demo Website-Analyse, Spezifikation 3.2).
|
||||
* Alle Zahlen sind Beispieldaten (letzte 30 Tage), die Summe ist 1.284 Klicks.
|
||||
* Diese Datei wird sofort geladen und darf kein CSS und keine Komponenten importieren.
|
||||
*/
|
||||
import type { ZonenRechteck } from "../heat";
|
||||
|
||||
export type DemoVarianteName = "desktop" | "handy";
|
||||
|
||||
/** Summe aller Klicks der Beispieldaten. */
|
||||
export const SUMME_KLICKS = 1284;
|
||||
|
||||
export type Zone = {
|
||||
id: string;
|
||||
/** So nennt ein Mensch die Stelle auf der Seite. */
|
||||
name: string;
|
||||
klicks: number;
|
||||
/** Toter Klick: Es wird oft geklickt, aber die Stelle ist kein Link. */
|
||||
tot: boolean;
|
||||
/** Beobachtung zu dieser Zone, nur beschreibend, ohne Versprechen. */
|
||||
satz: string;
|
||||
};
|
||||
|
||||
const prozent = (klicks: number) => Math.round((klicks / SUMME_KLICKS) * 100);
|
||||
|
||||
type Rohzone = { id: string; name: string; klicks: number; tot?: boolean; satz: (p: number) => string };
|
||||
|
||||
// Reihenfolge = Reihenfolge auf der Seite (oben links nach unten rechts)
|
||||
const ROH: Rohzone[] = [
|
||||
{
|
||||
id: "logo",
|
||||
name: "Logo",
|
||||
klicks: 32,
|
||||
satz: (p) => `Das Logo wird selten angeklickt – ${p}\u00a0% aller Klicks.`,
|
||||
},
|
||||
{
|
||||
id: "nav-routen",
|
||||
name: "Navigation „Routen“",
|
||||
klicks: 38,
|
||||
satz: (p) => `Im Menü liegt „Routen“ mit ${p}\u00a0% der Klicks hinter „Kurse“ auf Platz 2.`,
|
||||
},
|
||||
{
|
||||
id: "nav-kurse",
|
||||
name: "Navigation „Kurse“",
|
||||
klicks: 47,
|
||||
satz: (p) => `Im Menü wird „Kurse“ am häufigsten angeklickt – ${p}\u00a0% aller Klicks.`,
|
||||
},
|
||||
{
|
||||
id: "telefon",
|
||||
name: "Telefonnummer",
|
||||
klicks: 61,
|
||||
satz: (p) => `${p}\u00a0% der Klicks gehen auf die Telefonnummer im Kopf der Seite.`,
|
||||
},
|
||||
{
|
||||
id: "tageskarte",
|
||||
name: "Tageskarte kaufen",
|
||||
klicks: 402,
|
||||
satz: (p) => `Am meisten geklickt: „Tageskarte kaufen“ – ${p}\u00a0% aller Klicks.`,
|
||||
},
|
||||
{
|
||||
id: "routenbild",
|
||||
name: "Routenbild",
|
||||
klicks: 154,
|
||||
tot: true,
|
||||
satz: (p) =>
|
||||
`Auffällig: ${p}\u00a0% klicken auf das Routenbild. Es ist kein Link – hier würde einer passen.`,
|
||||
},
|
||||
{
|
||||
id: "auslastung",
|
||||
name: "Live-Auslastung",
|
||||
klicks: 187,
|
||||
satz: (p) => `Die Live-Auslastung liegt mit ${p}\u00a0% der Klicks auf Platz 2 – sie wird oft angeschaut.`,
|
||||
},
|
||||
{
|
||||
id: "schnupperkurs",
|
||||
name: "Schnupperkurs",
|
||||
klicks: 148,
|
||||
satz: (p) => `Der Schnupperkurs kommt auf ${p}\u00a0% der Klicks, knapp hinter dem Routenbild.`,
|
||||
},
|
||||
{
|
||||
id: "preise",
|
||||
name: "Preise",
|
||||
klicks: 119,
|
||||
satz: (p) => `${p}\u00a0% aller Klicks gehen auf die Karte „Preise“.`,
|
||||
},
|
||||
{
|
||||
id: "oeffnungszeiten",
|
||||
name: "Öffnungszeiten",
|
||||
klicks: 96,
|
||||
satz: (p) => `Die Öffnungszeiten bekommen ${p}\u00a0% der Klicks.`,
|
||||
},
|
||||
];
|
||||
|
||||
export const ZONEN: readonly Zone[] = ROH.map((z) => ({
|
||||
id: z.id,
|
||||
name: z.name,
|
||||
klicks: z.klicks,
|
||||
tot: z.tot === true,
|
||||
satz: z.satz(prozent(z.klicks)),
|
||||
}));
|
||||
|
||||
/** Zone zu einer id (undefined bei unbekannter id). */
|
||||
export const zoneNach = (id: string | null | undefined): Zone | undefined => ZONEN.find((z) => z.id === id);
|
||||
|
||||
/** Anteil einer Zone an allen Klicks in ganzen Prozent. Akzeptiert eine Zone oder ihre id. */
|
||||
export const anteil = (zone: Zone | string): number => {
|
||||
const z = typeof zone === "string" ? zoneNach(zone) : zone;
|
||||
return z ? prozent(z.klicks) : 0;
|
||||
};
|
||||
|
||||
/** Zonen nach Klicks absteigend (bei Gleichstand wie auf der Seite). Optional nur die ersten `anzahl`. */
|
||||
export const rangliste = (anzahl?: number): Zone[] => {
|
||||
const sortiert = [...ZONEN].sort((a, b) => b.klicks - a.klicks);
|
||||
return anzahl === undefined ? sortiert : sortiert.slice(0, anzahl);
|
||||
};
|
||||
|
||||
/** Die meistgeklickte Zone. */
|
||||
export const TOP_ZONE: Zone = rangliste(1)[0];
|
||||
|
||||
/** Text des Schildchens an der gewählten Zone: „Tageskarte kaufen · 402 Klicks · 31 %“. */
|
||||
export const schildText = (zone: Zone): string =>
|
||||
`${zone.name} · ${new Intl.NumberFormat("de-DE").format(zone.klicks)} Klicks · ${prozent(zone.klicks)}\u00a0%${zone.tot ? " · kein Link" : ""}`;
|
||||
|
||||
// ── Ersatz-Rechtecke ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Designgrößen der Flächen (wie FLAECHE in gemeinsam.tsx, hier ohne Import, weil gemeinsam.tsx CSS lädt). */
|
||||
export const FLAECHE_GROESSE: Record<DemoVarianteName, { breite: number; hoehe: number }> = {
|
||||
desktop: { breite: 1512, hoehe: 893 },
|
||||
handy: { breite: 402, hoehe: 874 },
|
||||
};
|
||||
|
||||
/** Rechteck in Designpunkten: x, y, Breite, Höhe. */
|
||||
export type PunktRechteck = { x: number; y: number; b: number; h: number };
|
||||
|
||||
/**
|
||||
* Feste Zonen-Rechtecke je Fassung in Designpunkten. Die Seite misst die echten Rechtecke zur Laufzeit;
|
||||
* diese Werte gelten, wo nicht gemessen werden kann (jsdom), und als Flugziele des simulierten Zeigers.
|
||||
* (Gemessen am gerenderten Layout; ändert sich das Layout, hier nachziehen.)
|
||||
*/
|
||||
export const ZONEN_LAYOUT: Record<DemoVarianteName, Record<string, PunktRechteck>> = {
|
||||
desktop: {
|
||||
logo: { x: 64, y: 29, b: 248, h: 60 },
|
||||
"nav-routen": { x: 453, y: 32, b: 139, h: 54 },
|
||||
"nav-kurse": { x: 600, y: 32, b: 122, h: 54 },
|
||||
telefon: { x: 1176, y: 27, b: 272, h: 64 },
|
||||
tageskarte: { x: 64, y: 481, b: 451, h: 88 },
|
||||
routenbild: { x: 808, y: 144, b: 640, h: 260 },
|
||||
oeffnungszeiten: { x: 808, y: 424, b: 330, h: 178 },
|
||||
auslastung: { x: 64, y: 620, b: 342, h: 248 },
|
||||
schnupperkurs: { x: 430, y: 620, b: 342, h: 248 },
|
||||
preise: { x: 796, y: 620, b: 342, h: 248 },
|
||||
},
|
||||
handy: {
|
||||
logo: { x: 16, y: 68, b: 149, h: 37 },
|
||||
telefon: { x: 245, y: 68, b: 141, h: 38 },
|
||||
"nav-routen": { x: 6, y: 119, b: 79, h: 35 },
|
||||
"nav-kurse": { x: 85, y: 119, b: 67, h: 35 },
|
||||
tageskarte: { x: 20, y: 298, b: 362, h: 58 },
|
||||
auslastung: { x: 20, y: 374, b: 362, h: 78 },
|
||||
routenbild: { x: 20, y: 466, b: 362, h: 150 },
|
||||
schnupperkurs: { x: 20, y: 630, b: 362, h: 66 },
|
||||
preise: { x: 20, y: 710, b: 176, h: 118 },
|
||||
oeffnungszeiten: { x: 206, y: 710, b: 176, h: 118 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Ersatz-Rechtecke einer Fassung, bezogen auf die Fläche (0 bis 1), für punkteInZone aus heat.ts. */
|
||||
export const ersatzRechtecke = (variante: DemoVarianteName): Record<string, ZonenRechteck> => {
|
||||
const { breite, hoehe } = FLAECHE_GROESSE[variante];
|
||||
const aus: Record<string, ZonenRechteck> = {};
|
||||
for (const [id, r] of Object.entries(ZONEN_LAYOUT[variante])) {
|
||||
aus[id] = { x: r.x / breite, y: r.y / hoehe, b: r.b / breite, h: r.h / hoehe };
|
||||
}
|
||||
return aus;
|
||||
};
|
||||
|
||||
/** Mitte einer Zone in Designpunkten, mit einem Versatz (0 bis 1) innerhalb des Rechtecks. */
|
||||
export const zonenPunkt = (variante: DemoVarianteName, id: string, fx = 0.5, fy = 0.5) => {
|
||||
const r = ZONEN_LAYOUT[variante][id];
|
||||
return { x: Math.round(r.x + r.b * fx), y: Math.round(r.y + r.h * fy) };
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { DemoZeiger } from "../gemeinsam";
|
||||
import { VEROEFFENTLICHEN_PUNKT, type PinnwandZustand } from "./ablauf";
|
||||
|
||||
// Zusätzlich hier verfügbar, wie in der Spezifikation der Demo vorgesehen.
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { VEROEFFENTLICHEN_PUNKT };
|
||||
|
||||
/** Wo der Finger wartet, bevor er zum Knopf gleitet (unten rechts). */
|
||||
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: PinnwandZustand;
|
||||
/** Mitte des Knopfes „Veröffentlichen“; Standard: VEROEFFENTLICHEN_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 „Veröffentlichen“ und tippt; danach fällt auf der Website der Zettel.
|
||||
*/
|
||||
const PinnwandHandy = ({ zustand, punkt = VEROEFFENTLICHEN_PUNKT }: Props) => {
|
||||
const ziel = zustand.phase === "warten" ? RUHE : punkt;
|
||||
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 PinnwandHandy;
|
||||
@@ -0,0 +1,380 @@
|
||||
import { useId } from "react";
|
||||
import type { DemoVariante } from "../gemeinsam";
|
||||
import { PINNWAND_START, type PinnwandZustand } from "./ablauf";
|
||||
import "./pinnwand.css";
|
||||
|
||||
/**
|
||||
* Die Beispiel-Website der Demo „News-Pinnwand“: erfundene Ortsgemeinde Musterbach (musterbach.example).
|
||||
* Desktop = 1512 x 893 (MacBook-Fläche), Handy = 402 x 874 (oben 59 / unten 34 frei für Statusleiste und Home-Balken).
|
||||
* Die Zettel sind der echten News-Pinnwand (embed/news.js) nachgebaut: Kork, Holzrahmen, Pastellzettel,
|
||||
* Polaroid mit Klebestreifen, Plakette „Neu“. Alle Bilder sind selbst gezeichnete SVG-Grafiken.
|
||||
* Die Website ist reine Kulisse: aria-hidden, keine fokussierbaren Elemente (div/span statt a/button).
|
||||
*/
|
||||
|
||||
// ── Grafiken ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Schlichtes erfundenes Wappen: Linde über einer Bachwelle in einem Schild. */
|
||||
const Wappen = ({ klasse }: { klasse?: string }) => {
|
||||
const id = useId().replace(/:/g, "");
|
||||
return (
|
||||
<svg className={klasse} viewBox="0 0 64 76" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<clipPath id={`${id}-schild`}>
|
||||
<path d="M32 3 59 11v26c0 17-13 29-27 35C18 66 5 54 5 37V11z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path d="M32 3 59 11v26c0 17-13 29-27 35C18 66 5 54 5 37V11z" fill="#f4ead3" />
|
||||
<g clipPath={`url(#${id}-schild)`}>
|
||||
<rect x="27.5" y="33" width="9" height="26" rx="2" fill="#7a5230" />
|
||||
<circle cx="32" cy="22" r="14" fill="#2f6b3f" />
|
||||
<circle cx="21" cy="30" r="10.5" fill="#3d8050" />
|
||||
<circle cx="43" cy="30" r="10.5" fill="#3d8050" />
|
||||
<circle cx="32" cy="30" r="9" fill="#2f6b3f" />
|
||||
<path d="M0 52c8 0 8-5 16-5s8 5 16 5 8-5 16-5 8 5 16 5v30H0z" fill="#4b86b4" />
|
||||
<path d="M0 61c8 0 8-5 16-5s8 5 16 5 8-5 16-5 8 5 16 5v20H0z" fill="#6aa0c8" />
|
||||
</g>
|
||||
<path d="M32 3 59 11v26c0 17-13 29-27 35C18 66 5 54 5 37V11z" fill="none" stroke="#fff" strokeWidth="3" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
/** Pony fürs Karussell (Körper, Hals, Kopf, Mähne, Beine), Farbe über currentColor. */
|
||||
const Pony = ({ x, y, farbe, spiegeln }: { x: number; y: number; farbe: string; spiegeln?: boolean }) => (
|
||||
<g transform={`translate(${x} ${y})${spiegeln ? " scale(-1 1)" : ""}`} color={farbe}>
|
||||
<ellipse cx="0" cy="0" rx="13" ry="7" fill="currentColor" />
|
||||
<path d="M7 -3 13 -14 20 -11 17 -3z" fill="currentColor" />
|
||||
<ellipse cx="19" cy="-11.5" rx="6.5" ry="3.6" transform="rotate(18 19 -11.5)" fill="currentColor" />
|
||||
<path d="M8 -4 12 -15" stroke="#fff6d6" strokeWidth="2.4" strokeLinecap="round" />
|
||||
<path d="M-12 -2c-6 1-7 7-6 11" fill="none" stroke="#fff6d6" strokeWidth="2.4" strokeLinecap="round" />
|
||||
<path d="M-7 5 -8 15M8 5 9 15" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" />
|
||||
</g>
|
||||
);
|
||||
|
||||
/** „Foto“ zum neuen Zettel: Kerwe-Karussell mit Lichterkette am Abend. */
|
||||
const FotoKerwe = () => {
|
||||
const id = useId().replace(/:/g, "");
|
||||
// Lichterkette: Punkte auf einer quadratischen Kurve (Durchhang)
|
||||
const kette = (y0: number, durchhang: number, anzahl: number) =>
|
||||
Array.from({ length: anzahl }, (_, i) => {
|
||||
const t = (i + 0.5) / anzahl;
|
||||
const x = -10 + 340 * t;
|
||||
const y = y0 + durchhang * 4 * t * (1 - t);
|
||||
return { x, y };
|
||||
});
|
||||
const oben = kette(20, 34, 12);
|
||||
const keile = Array.from({ length: 8 }, (_, i) => i);
|
||||
return (
|
||||
<svg viewBox="0 0 320 240" preserveAspectRatio="xMidYMid slice" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<linearGradient id={`${id}-himmel`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#14234a" />
|
||||
<stop offset="0.55" stopColor="#2e5288" />
|
||||
<stop offset="1" stopColor="#f0a45c" />
|
||||
</linearGradient>
|
||||
<radialGradient id={`${id}-glut`} cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stopColor="#ffd98a" stopOpacity="0.55" />
|
||||
<stop offset="1" stopColor="#ffd98a" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="320" height="240" fill={`url(#${id}-himmel)`} />
|
||||
{[[30, 18], [70, 44], [110, 12], [210, 40], [265, 16], [298, 52], [180, 22]].map(([x, y]) => (
|
||||
<circle key={`${x}-${y}`} cx={x} cy={y} r="1.3" fill="#fff" opacity="0.8" />
|
||||
))}
|
||||
<circle cx="160" cy="135" r="125" fill={`url(#${id}-glut)`} />
|
||||
{/* ferne Bäume */}
|
||||
<path d="M0 176c20-26 44-30 62-14 14-22 40-24 58-6 16-18 44-14 56 4 18-20 50-18 66 2 14-14 34-12 46 6 12 2 24 6 32 8v64H0z" fill="#1c3a40" />
|
||||
{/* Boden */}
|
||||
<rect y="190" width="320" height="50" fill="#1f3326" />
|
||||
<ellipse cx="160" cy="203" rx="104" ry="9" fill="#f0b865" opacity="0.28" />
|
||||
{/* Karussell: Sockel, Stangen, Ponys, Zeltdach */}
|
||||
<rect x="64" y="186" width="192" height="12" rx="3" fill="#8a5a34" />
|
||||
<rect x="64" y="186" width="192" height="3" fill="#c28a52" />
|
||||
{[88, 124, 160, 196, 232].map((x) => (
|
||||
<rect key={x} x={x - 1.6} y="112" width="3.2" height="76" fill="#f2d28a" />
|
||||
))}
|
||||
<Pony x={106} y={150} farbe="#f6ecd4" />
|
||||
<Pony x={142} y={160} farbe="#e07a3f" spiegeln />
|
||||
<Pony x={178} y={150} farbe="#f6ecd4" />
|
||||
<Pony x={214} y={160} farbe="#d9402f" spiegeln />
|
||||
<rect x="78" y="106" width="164" height="8" fill="#f2d28a" />
|
||||
{/* Dach mit Streifen */}
|
||||
{keile.map((i) => {
|
||||
const x0 = 70 + i * 22.5;
|
||||
const x1 = x0 + 22.5;
|
||||
return (
|
||||
<g key={i}>
|
||||
<path d={`M160 56 L${x0} 106 L${x1} 106 Z`} fill={i % 2 === 0 ? "#d9402f" : "#f6ecd4"} />
|
||||
<path d={`M${x0} 106 q11.25 12 22.5 0 z`} fill={i % 2 === 0 ? "#d9402f" : "#f6ecd4"} />
|
||||
<circle cx={x0 + 11.25} cy="113" r="2.6" fill="#ffd35a" />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<path d="M160 56v-17" stroke="#f2d28a" strokeWidth="2.4" />
|
||||
<path d="M160 39l17 6-17 6z" fill="#ffd35a" />
|
||||
<circle cx="160" cy="56" r="4" fill="#ffd35a" />
|
||||
{/* Lichterkette */}
|
||||
<path d={`M-10 20Q160 88 330 20`} fill="none" stroke="#10182e" strokeWidth="1.4" />
|
||||
{oben.map(({ x, y }, i) => (
|
||||
<g key={i}>
|
||||
<circle cx={x} cy={y + 3} r="8" fill="#ffd35a" opacity="0.28" />
|
||||
<circle cx={x} cy={y + 3} r="3.6" fill={i % 3 === 0 ? "#ff9a6a" : "#ffd35a"} />
|
||||
</g>
|
||||
))}
|
||||
{/* Weinstand der Feuerwehr links */}
|
||||
<rect x="10" y="156" width="46" height="34" fill="#4a3426" />
|
||||
<rect x="15" y="164" width="36" height="18" fill="#ffd98a" opacity="0.85" />
|
||||
<path d="M6 156 33 138l27 18z" fill="#c03a2b" />
|
||||
<path d="M6 156h54" stroke="#f6ecd4" strokeWidth="3" strokeDasharray="6 6" />
|
||||
<circle cx="262" cy="170" r="14" fill="#16302a" />
|
||||
<rect x="260" y="176" width="4" height="16" fill="#16302a" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
/** Foto zum Zettel „Neue Bücherzelle“: kleine Zelle am Spielplatz, Bücher im Regal. */
|
||||
const FotoBuecherzelle = () => {
|
||||
const id = useId().replace(/:/g, "");
|
||||
const buchfarben = ["#d9402f", "#2f6b3f", "#e8b53a", "#3b78c4", "#f6ecd4", "#c9633a", "#5a8f7b"];
|
||||
const regal = (y: number, versatz: number) =>
|
||||
Array.from({ length: 6 }, (_, i) => {
|
||||
const hoehe = 12 + ((i * 5 + versatz) % 7);
|
||||
return (
|
||||
<rect
|
||||
key={`${y}-${i}`}
|
||||
x={138 + i * 7.6}
|
||||
y={y - hoehe}
|
||||
width="6.2"
|
||||
height={hoehe}
|
||||
fill={buchfarben[(i + versatz) % buchfarben.length]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<svg viewBox="0 0 320 240" preserveAspectRatio="xMidYMid slice" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<linearGradient id={`${id}-himmel`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#a9d6ee" />
|
||||
<stop offset="1" stopColor="#e9f6fb" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="320" height="240" fill={`url(#${id}-himmel)`} />
|
||||
<circle cx="268" cy="44" r="30" fill="#ffe9a8" opacity="0.5" />
|
||||
<circle cx="268" cy="44" r="19" fill="#ffd96a" />
|
||||
<ellipse cx="70" cy="48" rx="34" ry="9" fill="#fff" opacity="0.9" />
|
||||
<ellipse cx="92" cy="40" rx="22" ry="9" fill="#fff" opacity="0.9" />
|
||||
<ellipse cx="214" cy="30" rx="26" ry="7" fill="#fff" opacity="0.8" />
|
||||
{/* Hügel und Wiese */}
|
||||
<path d="M0 150c50-26 110-30 170-14s110 6 150-6v110H0z" fill="#9ccb7a" />
|
||||
<path d="M0 176c60-14 120-16 180-6s100 4 140-4v74H0z" fill="#7fb35f" />
|
||||
{/* Schaukel links */}
|
||||
<path d="M22 196 40 124M78 196 60 124M34 132h32" stroke="#5d7389" strokeWidth="4" strokeLinecap="round" fill="none" />
|
||||
<path d="M44 132v40M56 132v40" stroke="#5d7389" strokeWidth="1.6" />
|
||||
<rect x="40" y="170" width="20" height="4" rx="2" fill="#e8b53a" />
|
||||
{/* Weg */}
|
||||
<path d="M130 240c10-28 22-44 34-50h30c12 8 24 24 34 50z" fill="#ead9ae" />
|
||||
{/* Bücherzelle */}
|
||||
<ellipse cx="164" cy="194" rx="46" ry="6" fill="#3e6b3a" opacity="0.35" />
|
||||
<path d="M114 92 128 62h72l14 30z" fill="#7d2a1f" />
|
||||
<rect x="120" y="92" width="88" height="8" fill="#a8362a" />
|
||||
<rect x="128" y="100" width="72" height="92" fill="#b8392b" />
|
||||
<rect x="136" y="106" width="56" height="80" fill="#4a3022" />
|
||||
{regal(124, 0)}
|
||||
{regal(146, 3)}
|
||||
{regal(168, 5)}
|
||||
{regal(186, 2)}
|
||||
<path d="M136 124h56M136 146h56M136 168h56" stroke="#8a5a34" strokeWidth="2.4" />
|
||||
<rect x="136" y="106" width="56" height="80" fill="#cfe9f2" opacity="0.28" />
|
||||
<path d="M164 106v80M136 126h56M136 146h56M136 166h56" stroke="#b8392b" strokeWidth="2.6" />
|
||||
<rect x="136" y="106" width="56" height="80" fill="none" stroke="#b8392b" strokeWidth="3.4" />
|
||||
<rect x="140" y="78" width="48" height="10" rx="2" fill="#f6ecd4" />
|
||||
<path d="M146 83h36" stroke="#b8392b" strokeWidth="3" strokeLinecap="round" strokeDasharray="5 3" />
|
||||
<circle cx="190" cy="148" r="2.4" fill="#e8b53a" />
|
||||
{/* Blumen */}
|
||||
{[[96, 206, "#f6ecd4"], [108, 214, "#e8b53a"], [234, 208, "#f6ecd4"], [248, 216, "#d9402f"], [222, 220, "#e8b53a"]].map(
|
||||
([x, y, f]) => (
|
||||
<g key={`${x}-${y}`}>
|
||||
<path d={`M${x} ${Number(y) + 8}v-8`} stroke="#4f8a45" strokeWidth="1.6" />
|
||||
<circle cx={x as number} cy={y as number} r="3.4" fill={f as string} />
|
||||
</g>
|
||||
),
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Zettel ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Papier = "sun" | "sky" | "coral" | "white";
|
||||
type PinneFarbe = "rot" | "blau" | "gelb" | "gruen";
|
||||
|
||||
type ZettelDaten = {
|
||||
id: string;
|
||||
art: "zettel" | "foto" | "neu";
|
||||
papier?: Papier;
|
||||
datum: string;
|
||||
titel: string;
|
||||
text: string;
|
||||
foto?: "buecherzelle" | "kerwe";
|
||||
pinne?: PinneFarbe;
|
||||
};
|
||||
|
||||
/** Der neue Zettel, den der Betrieb auf dem Handy veröffentlicht. */
|
||||
const NEUER_ZETTEL: ZettelDaten = {
|
||||
id: "kerwe",
|
||||
art: "neu",
|
||||
datum: "4. Okt",
|
||||
titel: "Kerwe 2026",
|
||||
text: "Freitag bis Montag auf dem Dorfplatz. Fahrgeschäfte, Musik und Weinstand der Feuerwehr.",
|
||||
foto: "kerwe",
|
||||
pinne: "rot",
|
||||
};
|
||||
|
||||
/** Zettel, die schon hängen. „sitzung“ liegt unter dem neuen Zettel und wird von ihm überdeckt. */
|
||||
const ALTE_ZETTEL: ZettelDaten[] = [
|
||||
{ id: "sitzung", art: "zettel", papier: "white", datum: "29. Sep", titel: "Sitzung des Gemeinderats", text: "Dienstag, 6. Okt., 19 Uhr im Bürgerhaus. Alle sind willkommen.", pinne: "blau" },
|
||||
{ id: "strasse", art: "zettel", papier: "sky", datum: "2. Okt", titel: "Hauptstraße ab 13.10. halbseitig gesperrt", text: "Kanalbau: Eine Ampel regelt den Verkehr.", pinne: "rot" },
|
||||
{ id: "buecherzelle", art: "foto", datum: "30. Sep", titel: "Neue Bücherzelle", text: "Am Spielplatz: bringen, tauschen, mitnehmen.", foto: "buecherzelle" },
|
||||
{ id: "sprechstunde", art: "zettel", papier: "sun", datum: "1. Okt", titel: "Sprechstunde", text: "Donnerstags 17–19 Uhr beim Ortsbürgermeister.", pinne: "gruen" },
|
||||
{ id: "gruenschnitt", art: "zettel", papier: "coral", datum: "25. Sep", titel: "Grünschnitt-Sammelstelle", text: "Samstags von 10 bis 13 Uhr am Bauhof.", pinne: "gelb" },
|
||||
];
|
||||
|
||||
type Platz = { x: number; y: number; b: number; r: number; wackel?: boolean };
|
||||
|
||||
/** Lage der Zettel auf der Korkfläche (Designpunkte, x/y = linke obere Ecke, b = Breite, r = Drehung in Grad). */
|
||||
const PLAETZE: Record<DemoVariante, Record<string, Platz>> = {
|
||||
desktop: {
|
||||
sitzung: { x: 326, y: 118, b: 290, r: -3 },
|
||||
strasse: { x: 26, y: 30, b: 292, r: -2, wackel: true },
|
||||
buecherzelle: { x: 34, y: 322, b: 292, r: -1.4 },
|
||||
sprechstunde: { x: 664, y: 14, b: 272, r: 1.8, wackel: true },
|
||||
gruenschnitt: { x: 652, y: 348, b: 276, r: -1.6 },
|
||||
kerwe: { x: 300, y: 76, b: 344, r: 2 },
|
||||
},
|
||||
handy: {
|
||||
sitzung: { x: 46, y: 70, b: 270, r: -3 },
|
||||
strasse: { x: 8, y: 372, b: 262, r: -2.2, wackel: true },
|
||||
buecherzelle: { x: 86, y: 568, b: 262, r: 1.6 },
|
||||
sprechstunde: { x: 20, y: 800, b: 270, r: 1.4 },
|
||||
gruenschnitt: { x: 70, y: 980, b: 262, r: -1.4 },
|
||||
kerwe: { x: 24, y: 22, b: 314, r: 1.8 },
|
||||
},
|
||||
};
|
||||
|
||||
const Pinne = ({ farbe }: { farbe: PinneFarbe }) => <span className={`pw-pinne pw-pinne--${farbe}`} />;
|
||||
|
||||
const Zettel = ({ daten, platz, variante }: { daten: ZettelDaten; platz: Platz; variante: DemoVariante }) => {
|
||||
const stil = { left: platz.x, top: platz.y, width: platz.b, ["--pw-r" as string]: `${platz.r}deg` };
|
||||
const neu = daten.art === "neu";
|
||||
const klassen = ["pw-zettel", neu ? "pw-zettel--neu" : "", platz.wackel ? "pw-wackel" : ""];
|
||||
if (daten.art === "zettel") {
|
||||
return (
|
||||
<div className={`${klassen.join(" ")} pw-papier pw-papier--${daten.papier}`} style={stil} data-zettel={daten.id}>
|
||||
<Pinne farbe={daten.pinne ?? "rot"} />
|
||||
<span className="pw-datum">{daten.datum}</span>
|
||||
<div className="pw-titel">{daten.titel}</div>
|
||||
<div className="pw-text">{daten.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Foto = daten.foto === "kerwe" ? FotoKerwe : FotoBuecherzelle;
|
||||
return (
|
||||
<div className={`${klassen.join(" ")} pw-polaroid`} style={stil} data-zettel={daten.id} data-variante={variante}>
|
||||
{neu ? <Pinne farbe={daten.pinne ?? "rot"} /> : <span className="pw-band" />}
|
||||
<div className="pw-foto">
|
||||
<Foto />
|
||||
</div>
|
||||
<div className="pw-unterschrift">
|
||||
<span className="pw-datum">{daten.datum}</span>
|
||||
<div className="pw-titel">{daten.titel}</div>
|
||||
<div className="pw-text">{daten.text}</div>
|
||||
</div>
|
||||
{neu ? <span className="pw-plakette">Neu</span> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Brett = ({ variante }: { variante: DemoVariante }) => (
|
||||
<div className="pw-brett">
|
||||
<div className="pw-kork">
|
||||
{ALTE_ZETTEL.map((z) => (
|
||||
<Zettel key={z.id} daten={z} platz={PLAETZE[variante][z.id]} variante={variante} />
|
||||
))}
|
||||
<Zettel daten={NEUER_ZETTEL} platz={PLAETZE[variante].kerwe} variante={variante} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Seite ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const NAVIGATION = ["Rathaus", "Leben in Musterbach", "Veranstaltungen", "Pinnwand"];
|
||||
|
||||
const Marke = () => (
|
||||
<div className="pw-marke">
|
||||
<Wappen klasse="pw-wappen" />
|
||||
<div className="pw-marke-text">
|
||||
<div className="pw-marke-klein">Ortsgemeinde</div>
|
||||
<div className="pw-marke-gross">Musterbach</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/** `zustand` fehlt, solange der Taktgeber noch lädt: dann steht die Website im Anfangszustand da. */
|
||||
type Props = { zustand?: PinnwandZustand; variante: DemoVariante };
|
||||
|
||||
/** Die Website auf der MacBook-Fläche (desktop) oder in der Handy-Fassung (handy). */
|
||||
const PinnwandSeite = ({ zustand = PINNWAND_START, variante }: Props) => (
|
||||
<div className="pw-seite" lang="de" aria-hidden="true" data-variante={variante} data-phase={zustand.phase}>
|
||||
<div className="pw-kopf">
|
||||
<Marke />
|
||||
{variante === "desktop" ? (
|
||||
<div className="pw-nav">
|
||||
{NAVIGATION.map((n) => (
|
||||
<div key={n} className={`pw-nav-eintrag${n === "Pinnwand" ? " is-aktiv" : ""}`}>
|
||||
{n}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="pw-menue">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{variante === "desktop" ? (
|
||||
<div className="pw-links">
|
||||
<div className="pw-h1">Neues aus Musterbach</div>
|
||||
<div className="pw-lead">Aktuelles aus Rathaus, Vereinen und Nachbarschaft – frisch an die Pinnwand geheftet.</div>
|
||||
<div className="pw-kachel">
|
||||
<span className="pw-punkt" />
|
||||
<div>
|
||||
<div className="pw-kachel-kopf">Rathaus</div>
|
||||
<div className="pw-kachel-gross">Heute geöffnet</div>
|
||||
<div className="pw-kachel-klein">8 bis 12 Uhr</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pw-kachel">
|
||||
<div className="pw-kalender">
|
||||
<div className="pw-kalender-kopf">Okt.</div>
|
||||
<div className="pw-kalender-tag">7</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="pw-kachel-kopf">Müllkalender</div>
|
||||
<div className="pw-kachel-gross">Biotonne</div>
|
||||
<div className="pw-kachel-klein">Mittwoch, 7. Okt.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pw-titelzeile">
|
||||
<div className="pw-h1">Neues aus Musterbach</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Brett variante={variante} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PinnwandSeite;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useAblauf } from "../gemeinsam";
|
||||
|
||||
/**
|
||||
* Ablauf der Demo „News-Pinnwand“ (Ortsgemeinde Musterbach), Spezifikation 3.3.
|
||||
* Reine Logik, kein CSS: die Komponenten werden später per React.lazy nachgeladen.
|
||||
*
|
||||
* Geschichte: warten (kurz Ruhe) -> tippen (Tipp-Welle auf dem Handy auf „Veröffentlichen“) ->
|
||||
* faellt (der neue Zettel fällt auf die Pinnwand) -> haengt (Plakette „Neu“, bleibt stehen) -> von vorn.
|
||||
* Der neue Zettel blendet beim Übergang haengt -> warten aus (das regelt das CSS über data-phase).
|
||||
*/
|
||||
|
||||
export type PinnwandPhase = "warten" | "tippen" | "faellt" | "haengt";
|
||||
|
||||
export type PinnwandZustand = {
|
||||
phase: PinnwandPhase;
|
||||
/** Zähler der Tipps für DemoZeiger: 0 solange nicht getippt wird, sonst eine je Durchlauf wachsende Zahl. */
|
||||
klick: number;
|
||||
/** Zähler der Durchläufe (wächst auch, wenn der Ablauf pausiert und wieder von vorn beginnt). */
|
||||
runde: number;
|
||||
/** true = keine Bewegung erlaubt: die Demo zeigt ihren Endzustand (Zettel hängt). */
|
||||
statisch: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Platzhalter: Mitte des Knopfes „Veröffentlichen“ im Portal-Screenshot (Designpunkte der Handy-Fläche 402 x 874).
|
||||
* Der Einbau setzt später die echte Position aus der Aufnahme ein (Prop `punkt` von PinnwandHandy).
|
||||
*/
|
||||
export const VEROEFFENTLICHEN_PUNKT = { x: 201, y: 770 } as const;
|
||||
|
||||
/** Innere Schritte: der Finger gleitet erst zum Knopf (tippen), dann folgt der Tipp (ebenfalls Phase „tippen“). */
|
||||
const SCHRITTE: readonly { phase: PinnwandPhase; dauer: number; tipp?: boolean }[] = [
|
||||
{ phase: "warten", dauer: 1500 },
|
||||
{ phase: "tippen", dauer: 1000 },
|
||||
{ phase: "tippen", dauer: 450, tipp: true },
|
||||
{ phase: "faellt", dauer: 1200 },
|
||||
{ phase: "haengt", dauer: 5000 },
|
||||
];
|
||||
const DAUERN = SCHRITTE.map((s) => s.dauer);
|
||||
|
||||
/** Anfangszustand (Phase „warten“): die Bühne zeigt die Website damit sofort, bevor der Taktgeber geladen ist. */
|
||||
export const PINNWAND_START: PinnwandZustand = { phase: "warten", klick: 0, runde: 0, statisch: false };
|
||||
|
||||
/** Gesamtdauer eines Durchlaufs in Millisekunden (für Tests und die Feinabstimmung der Bühne). */
|
||||
export const PINNWAND_RUNDE_MS = DAUERN.reduce((a, b) => a + b, 0);
|
||||
|
||||
/**
|
||||
* Taktgeber der Demo. `laeuft` = Station aktiv, im Bild, Tab sichtbar, Bewegung erlaubt.
|
||||
* Läuft sie nicht, steht die Demo in Phase „warten“ (nichts bewegt sich, kein Timer);
|
||||
* mit `statisch` zeigt sie sofort den Endzustand „haengt“.
|
||||
*/
|
||||
export const usePinnwandAblauf = (laeuft: boolean, statisch: boolean): PinnwandZustand => {
|
||||
const { schritt, runde } = useAblauf(laeuft && !statisch, DAUERN, true);
|
||||
if (statisch) return { phase: "haengt", klick: 0, runde, statisch: true };
|
||||
const aktuell = SCHRITTE[Math.min(schritt, SCHRITTE.length - 1)];
|
||||
return {
|
||||
phase: aktuell.phase,
|
||||
klick: aktuell.tipp || schritt > 2 ? runde + 1 : 0,
|
||||
runde,
|
||||
statisch: false,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,614 @@
|
||||
/*
|
||||
* Demo „News-Pinnwand“: Website der erfundenen Ortsgemeinde Musterbach.
|
||||
* Alle Klassen mit Präfix pw-. Desktop 1512 x 893, Handy 402 x 874 (oben 59 / unten 34 frei).
|
||||
* Grundzustand statisch (Zettel hängt); Bewegung nur transform/opacity hinter prefers-reduced-motion: no-preference.
|
||||
*/
|
||||
|
||||
.pw-seite {
|
||||
--pw-gruen: #2f6b3f;
|
||||
--pw-gruen-dunkel: #234f2f;
|
||||
--pw-sand: #e9dcc4;
|
||||
--pw-sand-hell: #f7f2e6;
|
||||
--pw-tinte: #232a33;
|
||||
--pw-tinte-leise: #55605a;
|
||||
--pw-papier-sun: #fff6d6;
|
||||
--pw-papier-sky: #ddeefb;
|
||||
--pw-papier-coral: #ffe1da;
|
||||
--pw-papier-white: #fff;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: var(--pw-sand-hell);
|
||||
color: var(--pw-tinte);
|
||||
font-family: "Instrument Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
letter-spacing: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
.pw-seite *,
|
||||
.pw-seite *::before,
|
||||
.pw-seite *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Kopf ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.pw-kopf {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 112px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 56px;
|
||||
background: var(--pw-gruen);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 0 rgb(0 0 0 / 0.1);
|
||||
z-index: 2;
|
||||
}
|
||||
.pw-marke {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.pw-wappen {
|
||||
display: block;
|
||||
width: 62px;
|
||||
height: 74px;
|
||||
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.25));
|
||||
}
|
||||
.pw-marke-klein {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
.pw-marke-gross {
|
||||
margin-top: 6px;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.pw-nav {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.pw-nav-eintrag {
|
||||
padding: 14px 24px;
|
||||
border-radius: 999px;
|
||||
color: rgb(255 255 255 / 0.9);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pw-nav-eintrag.is-aktiv {
|
||||
background: var(--pw-sand);
|
||||
color: var(--pw-gruen-dunkel);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Linke Spalte (Desktop) ────────────────────────────────────────────── */
|
||||
|
||||
.pw-links {
|
||||
position: absolute;
|
||||
left: 56px;
|
||||
top: 150px;
|
||||
width: 396px;
|
||||
}
|
||||
.pw-h1 {
|
||||
font-size: 66px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--pw-gruen-dunkel);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.pw-lead {
|
||||
margin-top: 24px;
|
||||
font-size: 24px;
|
||||
line-height: 1.45;
|
||||
color: var(--pw-tinte-leise);
|
||||
}
|
||||
.pw-kachel {
|
||||
margin-top: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 24px 28px;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 0 0 2px #e6dbc3,
|
||||
0 14px 26px -16px rgb(50 45 25 / 0.4);
|
||||
}
|
||||
.pw-lead + .pw-kachel {
|
||||
margin-top: 40px;
|
||||
}
|
||||
.pw-kachel-kopf {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pw-gruen);
|
||||
}
|
||||
.pw-kachel-gross {
|
||||
margin-top: 4px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.pw-kachel-klein {
|
||||
margin-top: 2px;
|
||||
font-size: 23px;
|
||||
color: var(--pw-tinte-leise);
|
||||
}
|
||||
.pw-punkt {
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #3fa35c;
|
||||
box-shadow: 0 0 0 8px rgb(63 163 92 / 0.2);
|
||||
margin: 0 8px;
|
||||
}
|
||||
.pw-kalender {
|
||||
flex: none;
|
||||
width: 84px;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 2px var(--pw-gruen);
|
||||
text-align: center;
|
||||
}
|
||||
.pw-kalender-kopf {
|
||||
background: var(--pw-gruen);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 30px;
|
||||
}
|
||||
.pw-kalender-tag {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
line-height: 62px;
|
||||
color: var(--pw-gruen-dunkel);
|
||||
}
|
||||
|
||||
/* ── Pinnwand: Holzrahmen, Kork ────────────────────────────────────────── */
|
||||
|
||||
.pw-brett {
|
||||
position: absolute;
|
||||
left: 486px;
|
||||
top: 142px;
|
||||
width: 970px;
|
||||
height: 722px;
|
||||
padding: 18px;
|
||||
border-radius: 30px;
|
||||
background:
|
||||
repeating-linear-gradient(94deg, rgb(255 255 255 / 0.05) 0 3px, rgb(60 30 5 / 0.05) 3px 7px),
|
||||
linear-gradient(145deg, #bb8851, #9d6c3a 55%, #a8733f);
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(90 55 25 / 0.55),
|
||||
inset 0 2px 0 rgb(255 235 200 / 0.35),
|
||||
0 34px 44px -26px rgb(60 35 10 / 0.55);
|
||||
}
|
||||
.pw-kork {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
background-color: #c08a52;
|
||||
background-image:
|
||||
url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27260%27 height=%27260%27%3E%3Cfilter id=%27a%27%3E%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%271.1%27 numOctaves=%272%27 seed=%273%27/%3E%3CfeColorMatrix values=%270 0 0 0 .29 0 0 0 0 .16 0 0 0 0 .06 0 0 0 -2.4 1.25%27/%3E%3C/filter%3E%3Crect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23a)%27/%3E%3C/svg%3E"),
|
||||
url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27220%27 height=%27220%27%3E%3Cfilter id=%27b%27%3E%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%27.85%27 numOctaves=%272%27 seed=%2711%27/%3E%3CfeColorMatrix values=%270 0 0 0 .95 0 0 0 0 .8 0 0 0 0 .58 0 0 0 2.6 -1.55%27/%3E%3C/filter%3E%3Crect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23b)%27/%3E%3C/svg%3E"),
|
||||
url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27600%27 height=%27600%27%3E%3Cfilter id=%27c%27%3E%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%27.012%27 numOctaves=%273%27 seed=%277%27/%3E%3CfeColorMatrix values=%270 0 0 0 .45 0 0 0 0 .27 0 0 0 0 .12 0 0 0 1.4 -.45%27/%3E%3C/filter%3E%3Crect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23c)%27/%3E%3C/svg%3E");
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgb(80 45 15 / 0.38),
|
||||
inset 0 8px 20px rgb(60 32 10 / 0.42);
|
||||
}
|
||||
|
||||
/* ── Zettel ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.pw-zettel {
|
||||
position: absolute;
|
||||
transform: rotate(var(--pw-r, 0deg));
|
||||
transform-origin: 50% 16px;
|
||||
color: var(--pw-tinte);
|
||||
}
|
||||
.pw-papier {
|
||||
padding: 40px 26px 28px;
|
||||
border-radius: 6px 6px 20px 6px;
|
||||
background: var(--pw-papier);
|
||||
box-shadow:
|
||||
0 12px 22px rgb(45 25 8 / 0.28),
|
||||
0 2px 3px rgb(45 25 8 / 0.2);
|
||||
}
|
||||
.pw-papier::after {
|
||||
/* weiche Papierkante unten rechts */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(160deg, rgb(255 255 255 / 0.5), rgb(255 255 255 / 0) 38%, rgb(120 90 40 / 0.07));
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
.pw-papier--sun {
|
||||
--pw-papier: var(--pw-papier-sun);
|
||||
}
|
||||
.pw-papier--sky {
|
||||
--pw-papier: var(--pw-papier-sky);
|
||||
}
|
||||
.pw-papier--coral {
|
||||
--pw-papier: var(--pw-papier-coral);
|
||||
}
|
||||
.pw-papier--white {
|
||||
--pw-papier: var(--pw-papier-white);
|
||||
}
|
||||
.pw-polaroid {
|
||||
padding: 14px 14px 22px;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 14px 24px rgb(45 25 8 / 0.3),
|
||||
0 2px 3px rgb(45 25 8 / 0.2);
|
||||
}
|
||||
.pw-foto {
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: #dfe6ea;
|
||||
}
|
||||
.pw-foto svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.pw-polaroid:not(.pw-zettel--neu) .pw-foto {
|
||||
aspect-ratio: 1.8 / 1;
|
||||
}
|
||||
.pw-unterschrift {
|
||||
padding: 16px 8px 0;
|
||||
}
|
||||
.pw-datum {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #5b6673;
|
||||
}
|
||||
.pw-titel {
|
||||
margin-top: 10px;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
line-height: 1.16;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--pw-tinte);
|
||||
}
|
||||
.pw-text {
|
||||
margin-top: 10px;
|
||||
font-size: 22px;
|
||||
line-height: 1.4;
|
||||
color: var(--pw-tinte);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.pw-polaroid .pw-titel {
|
||||
font-size: 28px;
|
||||
}
|
||||
.pw-zettel--neu .pw-titel {
|
||||
font-size: 42px;
|
||||
line-height: 1.05;
|
||||
}
|
||||
/* wie in der Zettelfarbe „Sonne“ des Portals: gelbes Papier mit eingeklebtem Foto */
|
||||
.pw-polaroid.pw-zettel--neu {
|
||||
background: var(--pw-papier-sun);
|
||||
}
|
||||
.pw-zettel--neu .pw-foto {
|
||||
box-shadow: 0 0 0 6px #fff;
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
/* Nadel (Reißzwecke) und Klebestreifen */
|
||||
.pw-pinne {
|
||||
position: absolute;
|
||||
left: calc(50% - 14px);
|
||||
top: 6px;
|
||||
z-index: 3;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
--pw-pf: #d9402f;
|
||||
background:
|
||||
radial-gradient(circle at 34% 30%, rgb(255 255 255 / 0.85) 0 9%, rgb(255 255 255 / 0) 30%),
|
||||
radial-gradient(circle at 50% 50%, var(--pw-pf) 0 55%, color-mix(in srgb, var(--pw-pf) 62%, #000) 100%);
|
||||
box-shadow:
|
||||
0 6px 6px -1px rgb(40 20 5 / 0.45),
|
||||
0 1px 2px rgb(40 20 5 / 0.5);
|
||||
}
|
||||
.pw-pinne--rot {
|
||||
--pw-pf: #d9402f;
|
||||
}
|
||||
.pw-pinne--blau {
|
||||
--pw-pf: #3b78c4;
|
||||
}
|
||||
.pw-pinne--gelb {
|
||||
--pw-pf: #f0b52e;
|
||||
}
|
||||
.pw-pinne--gruen {
|
||||
--pw-pf: #3a9a5b;
|
||||
}
|
||||
.pw-band {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -16px;
|
||||
z-index: 3;
|
||||
width: 104px;
|
||||
height: 32px;
|
||||
transform: translateX(-50%) rotate(-3deg);
|
||||
background: rgb(255 255 255 / 0.66);
|
||||
border-left: 3px dotted rgb(35 52 74 / 0.08);
|
||||
border-right: 3px dotted rgb(35 52 74 / 0.08);
|
||||
box-shadow: 0 2px 3px rgb(35 52 74 / 0.18);
|
||||
}
|
||||
|
||||
/* Plakette „Neu“ wie in der echten Pinnwand */
|
||||
.pw-plakette {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -16px;
|
||||
z-index: 4;
|
||||
padding: 11px 22px;
|
||||
border-radius: 999px;
|
||||
background: #e4573d;
|
||||
color: #fff;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.03em;
|
||||
transform: rotate(8deg);
|
||||
box-shadow: 0 5px 10px rgb(35 25 20 / 0.35);
|
||||
}
|
||||
|
||||
/* Der neue Zettel liegt über den anderen. Bevor er „veröffentlicht“ wird, ist er nicht da. */
|
||||
.pw-zettel--neu {
|
||||
z-index: 5;
|
||||
}
|
||||
.pw-seite[data-phase="warten"] .pw-zettel--neu,
|
||||
.pw-seite[data-phase="tippen"] .pw-zettel--neu {
|
||||
opacity: 0;
|
||||
}
|
||||
.pw-seite[data-phase="warten"] .pw-plakette,
|
||||
.pw-seite[data-phase="tippen"] .pw-plakette,
|
||||
.pw-seite[data-phase="faellt"] .pw-plakette {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ── Handy-Fassung (402 x 874, oben 59 / unten 34 frei) ───────────────── */
|
||||
|
||||
.pw-seite[data-variante="handy"] .pw-kopf {
|
||||
height: 127px;
|
||||
padding: 59px 20px 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-wappen {
|
||||
width: 40px;
|
||||
height: 48px;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-marke {
|
||||
gap: 12px;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-marke-klein {
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-marke-gross {
|
||||
margin-top: 4px;
|
||||
font-size: 27px;
|
||||
}
|
||||
.pw-menue {
|
||||
margin-left: auto;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: 30px;
|
||||
}
|
||||
.pw-menue i {
|
||||
display: block;
|
||||
height: 3.5px;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
}
|
||||
.pw-titelzeile {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
top: 142px;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-h1 {
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-brett {
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
top: 192px;
|
||||
width: auto;
|
||||
height: 760px;
|
||||
padding: 10px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-kork {
|
||||
border-radius: 13px;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-papier {
|
||||
padding: 32px 20px 22px;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-polaroid {
|
||||
padding: 10px 10px 16px;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-foto {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-unterschrift {
|
||||
padding: 12px 6px 0;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-datum {
|
||||
font-size: 15px;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-titel,
|
||||
.pw-seite[data-variante="handy"] .pw-polaroid .pw-titel {
|
||||
margin-top: 8px;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-zettel--neu .pw-titel {
|
||||
margin-top: 8px;
|
||||
font-size: 26px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-text {
|
||||
margin-top: 6px;
|
||||
font-size: 15.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-pinne {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
left: calc(50% - 11px);
|
||||
top: 5px;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-band {
|
||||
width: 84px;
|
||||
height: 26px;
|
||||
top: -13px;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] .pw-plakette {
|
||||
top: -16px;
|
||||
right: -10px;
|
||||
padding: 9px 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ── Bewegung: nur transform und opacity, nur ohne reduzierte Bewegung ─── */
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.pw-seite {
|
||||
--pw-fall: 660px;
|
||||
}
|
||||
.pw-seite[data-variante="handy"] {
|
||||
--pw-fall: 600px;
|
||||
}
|
||||
/* wieder weg: der neue Zettel blendet vor dem nächsten Durchlauf aus */
|
||||
.pw-zettel--neu {
|
||||
transition: opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.pw-seite[data-phase="faellt"] .pw-zettel--neu {
|
||||
animation: pw-fall 1200ms both;
|
||||
}
|
||||
.pw-seite[data-phase="faellt"] .pw-zettel--neu .pw-pinne {
|
||||
animation: pw-sticht 1200ms both;
|
||||
}
|
||||
.pw-seite[data-phase="haengt"] .pw-plakette {
|
||||
animation: pw-plakette 520ms cubic-bezier(0.2, 0.8, 0.2, 1) 120ms both;
|
||||
}
|
||||
/* die Nachbarn wackeln beim Aufprall leicht mit */
|
||||
.pw-seite[data-phase="faellt"] .pw-wackel {
|
||||
animation: pw-wackeln 800ms ease-in-out 480ms both;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pw-fall {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(calc(var(--pw-fall) * -1)) rotate(calc(var(--pw-r) - 8deg));
|
||||
animation-timing-function: cubic-bezier(0.55, 0.05, 0.85, 0.5);
|
||||
}
|
||||
6% {
|
||||
opacity: 1;
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: translateY(8px) rotate(calc(var(--pw-r) + 7deg));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
54% {
|
||||
transform: translateY(0) rotate(calc(var(--pw-r) - 4.5deg));
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
68% {
|
||||
transform: translateY(0) rotate(calc(var(--pw-r) + 2.6deg));
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
82% {
|
||||
transform: translateY(0) rotate(calc(var(--pw-r) - 1deg));
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) rotate(var(--pw-r));
|
||||
}
|
||||
}
|
||||
@keyframes pw-sticht {
|
||||
0%,
|
||||
38% {
|
||||
opacity: 0;
|
||||
transform: scale(1.9);
|
||||
}
|
||||
44% {
|
||||
opacity: 1;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
56% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes pw-plakette {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: rotate(8deg) scale(0.4);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: rotate(8deg) scale(1.15);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: rotate(8deg) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes pw-wackeln {
|
||||
0% {
|
||||
transform: rotate(var(--pw-r));
|
||||
}
|
||||
25% {
|
||||
transform: rotate(calc(var(--pw-r) + 1.1deg));
|
||||
}
|
||||
55% {
|
||||
transform: rotate(calc(var(--pw-r) - 0.7deg));
|
||||
}
|
||||
80% {
|
||||
transform: rotate(calc(var(--pw-r) + 0.3deg));
|
||||
}
|
||||
100% {
|
||||
transform: rotate(var(--pw-r));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { memo, useEffect } from "react";
|
||||
import { useTerminAblauf, type TerminZustand } from "./termin/ablauf";
|
||||
import { useHeatmapAblauf, type HeatmapZustand } from "./heatmap/ablauf";
|
||||
import { usePinnwandAblauf, type PinnwandZustand } from "./pinnwand/ablauf";
|
||||
|
||||
/**
|
||||
* Taktgeber der drei Funktions-Demos 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.
|
||||
* `laeuft` und `statisch` kommen von useDemoLauf (Station aktiv, im Bild, Tab sichtbar, Bewegung erlaubt).
|
||||
* Die Bauteile sind memoisiert: sie rechnen nur neu, wenn sich ihr eigener Ablauf bewegt, nicht bei jedem Rendern der Bühne.
|
||||
*/
|
||||
|
||||
type Props<Z> = { laeuft: boolean; statisch: boolean; onZustand: (zustand: Z) => void };
|
||||
|
||||
export const TerminSteuerung = memo(function TerminSteuerung({ laeuft, statisch, onZustand }: Props<TerminZustand>) {
|
||||
const zustand = useTerminAblauf(laeuft, statisch);
|
||||
useEffect(() => onZustand(zustand), [zustand, onZustand]);
|
||||
return null;
|
||||
});
|
||||
|
||||
export const PinnwandSteuerung = memo(function PinnwandSteuerung({ laeuft, statisch, onZustand }: Props<PinnwandZustand>) {
|
||||
const zustand = usePinnwandAblauf(laeuft, statisch);
|
||||
useEffect(() => onZustand(zustand), [zustand, onZustand]);
|
||||
return null;
|
||||
});
|
||||
|
||||
/** `aktiv`: Station ist die aktive; sonst bleiben weder Maus-Zustand noch Auswahl der Heatmap hängen. */
|
||||
export const HeatmapSteuerung = memo(function HeatmapSteuerung({
|
||||
laeuft,
|
||||
statisch,
|
||||
aktiv,
|
||||
onZustand,
|
||||
}: Props<HeatmapZustand> & { aktiv: boolean }) {
|
||||
const zustand = useHeatmapAblauf(laeuft, statisch);
|
||||
const { setzeMaus, waehle } = zustand;
|
||||
useEffect(() => {
|
||||
if (!aktiv) {
|
||||
setzeMaus(false);
|
||||
waehle(null);
|
||||
}
|
||||
}, [aktiv, setzeMaus, waehle]);
|
||||
useEffect(() => onZustand(zustand), [zustand, onZustand]);
|
||||
return null;
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { TerminZustand } from "./ablauf";
|
||||
import "./termin.css";
|
||||
|
||||
/**
|
||||
* Live-Ebene über dem Portal-Screenshot im iPhone der Bühne (402 x 874, transparent, ohne Zeigerereignisse):
|
||||
* In der Phase „kalender“ gleitet oben eine Mitteilung im iOS-Stil herein, so wie sie der Betrieb bei einer
|
||||
* neuen Buchung per E-Mail bekommt (K20, Absender „WEBklar“, Betreff „Neue Buchung: {Leistung} am {Datum} um {Zeit}“).
|
||||
* Ohne Bewegung steht sie fest da.
|
||||
*/
|
||||
const TerminHandy = ({ zustand }: { zustand: TerminZustand }) => (
|
||||
<div className="tsh" data-phase={zustand.phase} aria-hidden="true">
|
||||
{zustand.phase === "kalender" ? (
|
||||
<div className="tsh-mitteilung" data-testid="ts-mitteilung">
|
||||
<span className="tsh-icon">
|
||||
<svg viewBox="0 0 48 48" aria-hidden="true">
|
||||
<rect x="8" y="12" width="32" height="24" rx="5" fill="none" stroke="currentColor" strokeWidth="3.2" />
|
||||
<path d="m10 16 14 11 14-11" fill="none" stroke="currentColor" strokeWidth="3.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="tsh-text">
|
||||
<span className="tsh-zeile">
|
||||
<b>WEBklar</b>
|
||||
<span>jetzt</span>
|
||||
</span>
|
||||
<span className="tsh-betreff">Neue Buchung: Damenhaarschnitt am Do., 8. Okt. um 14:30</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default TerminHandy;
|
||||
@@ -0,0 +1,390 @@
|
||||
import { DemoZeiger } from "../gemeinsam";
|
||||
import type { DemoVariante } from "../gemeinsam";
|
||||
import { TERMIN_START, type TerminZustand } from "./ablauf";
|
||||
import "./termin.css";
|
||||
|
||||
/**
|
||||
* Beispiel-Website „Salon Schnittstelle“ (erfundener Friseur, salon-schnittstelle.example) mit Buchungs-Widget
|
||||
* (Optik angelehnt an embed/termine.js, als statische Attrappe) und einem Kalender-Fenster im Stil einer Kalender-App.
|
||||
* Alles ist selbst gezeichnet (SVG/CSS), nichts ist fokussierbar, die Wurzel ist aria-hidden.
|
||||
* Gesetzt in 1512 x 893 (desktop) bzw. 402 x 874 (handy, oben 59 / unten 34 frei für Statusleiste und Home-Balken).
|
||||
*/
|
||||
|
||||
const LEISTUNGEN = [
|
||||
{ id: "damen", name: "Damenhaarschnitt", meta: "45 Min · 48 €" },
|
||||
{ id: "herren", name: "Herrenhaarschnitt", meta: "30 Min · 29 €" },
|
||||
{ id: "faerben", name: "Färben", meta: "90 Min · ab 65 €", anfrage: true },
|
||||
{ id: "bart", name: "Bartpflege", meta: "20 Min · 18 €" },
|
||||
] as const;
|
||||
|
||||
const PERSONEN = ["Mia", "Can", "Lea", "Egal"] as const;
|
||||
const ZEITEN = ["10:00", "11:15", "14:30", "16:00"] as const;
|
||||
|
||||
/** Der Eintrag, den die Demo bucht (steht auch in der Mitteilung und in der Karte auf dem Handy). */
|
||||
export const NEUER_EINTRAG = "Damenhaarschnitt: Laura W.";
|
||||
|
||||
// ── Kalender (Wochenansicht Mi 7. – Sa 10. Okt. 2026, 10 – 16 Uhr) ───────────────────────────────────────────
|
||||
// Bewusst wenig und groß: im verkleinerten Rahmen (Faktor ~0,41) muss man Leistung und Farbe noch erkennen.
|
||||
// Die Farbe steht für die Leistung (keine Markenfarben eines Kalender-Anbieters). Die Spalten rechts liegen
|
||||
// unter dem Handy nicht: alles Wichtige bleibt links von x 1140 (siehe Handy-Überdeckung in kundenportal.css).
|
||||
|
||||
const TAGE = ["Mi 7", "Do 8", "Fr 9", "Sa 10"] as const;
|
||||
const STUNDEN = [10, 11, 12, 13, 14, 15] as const;
|
||||
/** Erste sichtbare Stunde (das Raster zeigt 10 – 16 Uhr, also 360 Minuten; termin.css rechnet damit) */
|
||||
const ERSTE_STUNDE = 10;
|
||||
|
||||
type Art = "damen" | "herren" | "faerben";
|
||||
type Eintrag = { tag: number; von: string; minuten: number; art: Art; gast: string };
|
||||
const KURZ: Record<Art, string> = { damen: "Damen", herren: "Herren", faerben: "Färben" };
|
||||
const EINTRAEGE: readonly Eintrag[] = [
|
||||
{ tag: 0, von: "11:00", minuten: 90, art: "faerben", gast: "Elke M." },
|
||||
{ tag: 0, von: "15:00", minuten: 30, art: "herren", gast: "Marco H." },
|
||||
{ tag: 1, von: "10:00", minuten: 30, art: "herren", gast: "Ben A." },
|
||||
{ tag: 1, von: "12:30", minuten: 90, art: "faerben", gast: "Ines T." },
|
||||
{ tag: 2, von: "10:30", minuten: 45, art: "damen", gast: "Maja G." },
|
||||
{ tag: 2, von: "15:00", minuten: 30, art: "herren", gast: "Lukas P." },
|
||||
{ tag: 3, von: "10:00", minuten: 45, art: "damen", gast: "Nadine H." },
|
||||
{ tag: 3, von: "12:00", minuten: 90, art: "faerben", gast: "Petra S." },
|
||||
];
|
||||
const NEU: Eintrag = { tag: 1, von: "14:30", minuten: 45, art: "damen", gast: "Laura W." };
|
||||
|
||||
const minutenAbBeginn = (uhr: string) => {
|
||||
const [h, m] = uhr.split(":").map(Number);
|
||||
return (h - ERSTE_STUNDE) * 60 + m;
|
||||
};
|
||||
|
||||
const EintragBlock = ({ e, neu }: { e: Eintrag; neu?: boolean }) => (
|
||||
<div
|
||||
className="ts-kal-eintrag"
|
||||
data-art={e.art}
|
||||
data-neu={neu ? "ja" : undefined}
|
||||
data-kurz={e.minuten <= 45 ? "ja" : undefined}
|
||||
data-testid={neu ? "ts-kal-neu" : undefined}
|
||||
style={{ ["--von" as string]: minutenAbBeginn(e.von), ["--dauer" as string]: e.minuten }}
|
||||
>
|
||||
<b>{KURZ[e.art]}</b>
|
||||
<span>{e.gast}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const KalenderSymbol = () => (
|
||||
<svg className="ts-kal-symbol" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<rect x="3.5" y="6" width="25" height="22" rx="4.5" fill="#fff" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M3.5 12.5h25" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M10 3v6M22 3v6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<rect x="9" y="17" width="5" height="4" rx="1" fill="currentColor" />
|
||||
<rect x="17" y="17" width="5" height="4" rx="1" fill="currentColor" opacity=".45" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Kalender = ({ z }: { z: TerminZustand }) => {
|
||||
const neu = z.phase === "kalender";
|
||||
return (
|
||||
<div className="ts-kal" data-neu={neu ? "ja" : "nein"}>
|
||||
<div className="ts-kal-kopf">
|
||||
<KalenderSymbol />
|
||||
<div className="ts-kal-titel">
|
||||
<b>Kalender-Abo · Mia</b>
|
||||
<span>z. B. in Google Kalender</span>
|
||||
</div>
|
||||
<span className="ts-kal-woche">7.–10. Okt.</span>
|
||||
</div>
|
||||
<div className="ts-kal-tage">
|
||||
<span />
|
||||
{TAGE.map((t) => (
|
||||
<span key={t} data-do={t === "Do 8" ? "ja" : undefined}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="ts-kal-raster">
|
||||
<div className="ts-kal-stunden">
|
||||
{STUNDEN.map((h, i) => (
|
||||
<span key={h} style={{ ["--i" as string]: i }}>
|
||||
{h}:00
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="ts-kal-spalten">
|
||||
{TAGE.map((t, i) => (
|
||||
<div key={t} className="ts-kal-spalte">
|
||||
{EINTRAEGE.filter((e) => e.tag === i).map((e) => (
|
||||
<EintragBlock key={e.von} e={e} />
|
||||
))}
|
||||
{neu && i === NEU.tag ? <EintragBlock e={NEU} neu /> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{neu ? (
|
||||
<div className="ts-kal-info" data-testid="ts-kal-info">
|
||||
<span className="ts-kal-info-quelle">Neu im Kalender-Abo</span>
|
||||
<b>{NEUER_EINTRAG}</b>
|
||||
<span className="ts-kal-info-zeit">Do., 8. Okt. · 14:30–15:15</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Handy-Fassung: kleine Tagesliste des Kalender-Abos unter dem Widget; die gebuchte Zeile kommt nach der Buchung dazu. */
|
||||
const MiniKalender = ({ neu }: { neu: boolean }) => (
|
||||
<div className="ts-mini" data-neu={neu ? "ja" : "nein"} data-testid="ts-handy-karte">
|
||||
<div className="ts-mini-kopf">
|
||||
<KalenderSymbol />
|
||||
<b>Kalender-Abo · Mia</b>
|
||||
<span>Do 8. Okt.</span>
|
||||
</div>
|
||||
<div className="ts-mini-zeile" data-art="herren">
|
||||
<i />
|
||||
<span>10:00</span>
|
||||
<b>Herren</b>
|
||||
</div>
|
||||
<div className="ts-mini-zeile" data-art="faerben">
|
||||
<i />
|
||||
<span>12:30</span>
|
||||
<b>Färben</b>
|
||||
</div>
|
||||
<div className="ts-mini-zeile ts-mini-zeile--neu" data-art="damen" data-an={neu ? "ja" : "nein"}>
|
||||
<i />
|
||||
<span>14:30</span>
|
||||
<b>{NEUER_EINTRAG}</b>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Zeichnungen ───────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Schere und Kamm als gefüllte Zeichnung (selbst gezeichnet): Griffe in Akzentfarbe, Klingen in Tinte */
|
||||
const Schere = () => (
|
||||
<svg className="ts-schere" viewBox="0 0 320 320" aria-hidden="true">
|
||||
<circle cx="170" cy="170" r="146" fill="var(--ts-sand)" />
|
||||
{/* Kamm */}
|
||||
<g transform="rotate(18 262 168)">
|
||||
<rect x="236" y="96" width="28" height="144" rx="10" fill="var(--ts-karte)" stroke="var(--ts-tinte)" strokeWidth="5" />
|
||||
<g fill="var(--ts-tinte)">
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<rect key={i} x="262" y={104 + i * 17} width="26" height="7" rx="3.5" />
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
{/* Griffe */}
|
||||
<g fill="none" stroke="var(--ts-akzent)" strokeWidth="16">
|
||||
<circle cx="112" cy="254" r="31" />
|
||||
<circle cx="206" cy="254" r="31" />
|
||||
</g>
|
||||
{/* Klingen */}
|
||||
<g fill="none" stroke="var(--ts-tinte)" strokeWidth="13" strokeLinecap="round">
|
||||
<path d="M238 46 126 216" />
|
||||
<path d="M82 46 194 216" />
|
||||
</g>
|
||||
<circle cx="160" cy="166" r="10" fill="var(--ts-karte)" stroke="var(--ts-tinte)" strokeWidth="5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Logo = () => (
|
||||
<svg className="ts-logo-zeichen" viewBox="0 0 40 40" aria-hidden="true">
|
||||
<circle cx="20" cy="20" r="19" fill="var(--ts-akzent)" />
|
||||
<g fill="none" stroke="#fff" strokeWidth="2.6" strokeLinecap="round">
|
||||
<circle cx="13.5" cy="28" r="4.2" />
|
||||
<circle cx="26.5" cy="28" r="4.2" />
|
||||
<path d="M15.5 24.3 24.6 9M24.5 24.3 15.4 9" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Haken = () => (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m5.5 12.8 4.2 4.2 8.8-9.6" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Brief = () => (
|
||||
<svg viewBox="0 0 48 48" aria-hidden="true">
|
||||
<rect x="6" y="11" width="36" height="26" rx="5" fill="none" stroke="currentColor" strokeWidth="3" />
|
||||
<path d="m8 15 16 12 16-12" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── Buchungs-Widget ────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const Buchung = ({ z }: { z: TerminZustand }) => {
|
||||
const { gewaehlt: g, phase } = z;
|
||||
const ergebnis = phase === "mail" || phase === "bestaetigt" || phase === "kalender";
|
||||
return (
|
||||
<div className="ts-widget" data-ergebnis={ergebnis ? "ja" : "nein"}>
|
||||
<div className="ts-auswahl">
|
||||
<div className="ts-w-kopf">
|
||||
<h2 className="ts-w-titel">Termin buchen</h2>
|
||||
</div>
|
||||
|
||||
<section className="ts-schritt" data-aktiv="ja">
|
||||
<h3 className="ts-label">
|
||||
<span className="ts-nr">1</span>Leistung
|
||||
</h3>
|
||||
<div className="ts-leistungen">
|
||||
{LEISTUNGEN.map((l) => {
|
||||
const an = g.leistung && l.id === "damen";
|
||||
return (
|
||||
<div
|
||||
key={l.id}
|
||||
className="ts-karte"
|
||||
data-an={an ? "ja" : undefined}
|
||||
data-ziel={l.id === "damen" ? "leistung" : undefined}
|
||||
>
|
||||
<span className="ts-karte-name">
|
||||
{l.name}
|
||||
{"anfrage" in l ? <em className="ts-pille">auf Anfrage</em> : null}
|
||||
</span>
|
||||
<span className="ts-karte-meta">{l.meta}</span>
|
||||
{an ? <span className="ts-haken"><Haken /></span> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ts-schritt" data-aktiv={g.leistung ? "ja" : "nein"}>
|
||||
<h3 className="ts-label">
|
||||
<span className="ts-nr">2</span>Person
|
||||
</h3>
|
||||
<div className="ts-chips">
|
||||
{PERSONEN.map((p) => (
|
||||
<div
|
||||
key={p}
|
||||
className="ts-chip"
|
||||
data-an={g.person && p === "Mia" ? "ja" : undefined}
|
||||
data-ziel={p === "Mia" ? "person" : undefined}
|
||||
>
|
||||
{p}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ts-schritt" data-aktiv={g.person ? "ja" : "nein"}>
|
||||
<h3 className="ts-label">
|
||||
<span className="ts-nr">3</span>Freie Zeiten am Do., 8. Okt.
|
||||
</h3>
|
||||
<div className="ts-chips">
|
||||
{ZEITEN.map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
className="ts-chip"
|
||||
data-an={g.zeit && t === "14:30" ? "ja" : undefined}
|
||||
data-ziel={t === "14:30" ? "zeit" : undefined}
|
||||
>
|
||||
{t}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ts-schritt ts-daten" data-aktiv={g.zeit ? "ja" : "nein"}>
|
||||
<h3 className="ts-label">
|
||||
<span className="ts-nr">4</span>Ihre Daten
|
||||
</h3>
|
||||
<div className="ts-felder">
|
||||
<div className="ts-feld" data-gefuellt={g.zeit ? "ja" : "nein"}>
|
||||
{g.zeit ? "Laura W." : "Name"}
|
||||
</div>
|
||||
<div className="ts-feld" data-gefuellt={g.zeit ? "ja" : "nein"}>
|
||||
{g.zeit ? "laura@beispiel.example" : "E-Mail"}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
className="ts-knopf"
|
||||
data-bereit={g.zeit ? "ja" : "nein"}
|
||||
data-gedrueckt={z.gedrueckt ? "ja" : "nein"}
|
||||
data-ziel="buchen"
|
||||
>
|
||||
Termin buchen
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ts-erg ts-erg--mail" data-an={phase === "mail" ? "ja" : "nein"}>
|
||||
<span className="ts-erg-symbol">
|
||||
<Brief />
|
||||
</span>
|
||||
<h2 className="ts-erg-titel">Fast geschafft</h2>
|
||||
<p className="ts-erg-text">Bitte bestätigen Sie den Termin über den Link in Ihrer E-Mail.</p>
|
||||
<p className="ts-erg-zeile">
|
||||
<b>Damenhaarschnitt bei Mia</b>
|
||||
<span>Do., 8. Okt. 2026 · 14:30 Uhr</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="ts-erg ts-erg--ok" data-an={phase === "bestaetigt" || phase === "kalender" ? "ja" : "nein"}>
|
||||
<span className="ts-erg-symbol ts-erg-symbol--ok">
|
||||
<Haken />
|
||||
</span>
|
||||
<h2 className="ts-erg-titel">Termin bestätigt</h2>
|
||||
<p className="ts-erg-text">Danke, Laura. Wir freuen uns auf Sie.</p>
|
||||
<p className="ts-erg-zeile">
|
||||
<b>Damenhaarschnitt bei Mia</b>
|
||||
<span>Do., 8. Okt. 2026 · 14:30 Uhr</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seite ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** `zustand` fehlt, solange der Taktgeber noch lädt: dann steht die Website im Anfangszustand da. */
|
||||
type SeiteProps = { zustand?: TerminZustand; variante: DemoVariante };
|
||||
|
||||
/** Die Website auf der Fläche (variante desktop = 1512 x 893, handy = 402 x 874). */
|
||||
const TerminSeite = ({ zustand: z = TERMIN_START, variante }: SeiteProps) => {
|
||||
const handy = variante === "handy";
|
||||
return (
|
||||
<div className="ts" data-variante={variante} data-phase={z.phase} aria-hidden="true">
|
||||
<header className="ts-kopf">
|
||||
<div className="ts-logo">
|
||||
<Logo />
|
||||
<span>Salon Schnittstelle</span>
|
||||
</div>
|
||||
{handy ? (
|
||||
<span className="ts-menue">
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
) : (
|
||||
<nav className="ts-nav">
|
||||
<span>Leistungen</span>
|
||||
<span>Team</span>
|
||||
<span>Preise</span>
|
||||
<span>Kontakt</span>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<section className="ts-hero">
|
||||
<p className="ts-augenbraue">Friseur für Damen und Herren</p>
|
||||
<h1 className="ts-h1">
|
||||
Ihr Haar.
|
||||
<br />
|
||||
Ihr Termin.
|
||||
</h1>
|
||||
{handy ? null : <p className="ts-lead">Schneiden, färben, pflegen. Online buchen.</p>}
|
||||
<Schere />
|
||||
</section>
|
||||
|
||||
<Buchung z={z} />
|
||||
|
||||
{handy ? <MiniKalender neu={z.phase === "kalender"} /> : <Kalender z={z} />}
|
||||
|
||||
<DemoZeiger
|
||||
x={z.zeiger[variante].x}
|
||||
y={z.zeiger[variante].y}
|
||||
art={handy ? "finger" : "maus"}
|
||||
klick={z.klick}
|
||||
sichtbar={z.zeigerSichtbar}
|
||||
groesse={handy ? 1 : 1.6}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminSeite;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useAblauf } from "../gemeinsam";
|
||||
|
||||
/**
|
||||
* Ablauf der Demo „Terminplaner“ (Salon Schnittstelle, Spezifikation 2026-10-04-funktionen-demos 3.1).
|
||||
* Bewusst klein und ohne CSS: wird sofort geladen, die Komponenten (TerminSeite, TerminHandy) kommen per React.lazy nach.
|
||||
* Eine Quelle der Wahrheit für die Website (Desktop und Handy) und die Mitteilung im Handy der Bühne.
|
||||
*/
|
||||
|
||||
export type TerminPhase = "start" | "leistung" | "person" | "zeit" | "buchen" | "mail" | "bestaetigt" | "kalender";
|
||||
|
||||
export type Punkt = { x: number; y: number };
|
||||
|
||||
export type TerminZustand = {
|
||||
phase: TerminPhase;
|
||||
/** Welche Teile der Auswahl sichtbar markiert sind (Damenhaarschnitt, Mia, 14:30) */
|
||||
gewaehlt: { leistung: boolean; person: boolean; zeit: boolean };
|
||||
/** Der Knopf „Termin buchen“ ist gerade gedrückt */
|
||||
gedrueckt: boolean;
|
||||
/** Zeigerspitze je Variante in Designpunkten (desktop 1512 x 893, handy 402 x 874) */
|
||||
zeiger: { desktop: Punkt; handy: Punkt };
|
||||
zeigerSichtbar: boolean;
|
||||
/** Zähler für DemoZeiger.klick: jede Änderung löst eine Klickwelle aus (0 = noch kein Klick) */
|
||||
klick: number;
|
||||
/** Keine Bewegung erlaubt: Endzustand ohne Zeiger */
|
||||
statisch: boolean;
|
||||
laeuft: boolean;
|
||||
};
|
||||
|
||||
/** Dauer des letzten Schritts (Phase „kalender“): die Mitteilung und die Karte blenden darin ein und wieder aus (termin.css). */
|
||||
export const KALENDER_MS = 3300;
|
||||
|
||||
type Schritt = {
|
||||
phase: TerminPhase;
|
||||
ms: number;
|
||||
gewaehlt?: number; // 0 nichts, 1 Leistung, 2 + Person, 3 + Zeit
|
||||
gedrueckt?: boolean;
|
||||
/** Schlüssel in ZEIGER */
|
||||
ziel: keyof typeof ZEIGER;
|
||||
klick?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Zeigerziele (Mitte des jeweiligen Felds) – passend zum festen Layout in termin.css.
|
||||
* Desktop: alle Ziele liegen im Widget links (x < 590); das iPhone der Bühne überdeckt die Fläche ab x 1158 / y 419.
|
||||
*/
|
||||
export const ZEIGER = {
|
||||
start: { desktop: { x: 1100, y: 252 }, handy: { x: 340, y: 300 } },
|
||||
leistung: { desktop: { x: 194, y: 285 }, handy: { x: 128, y: 368 } },
|
||||
person: { desktop: { x: 126, y: 502 }, handy: { x: 80, y: 528 } },
|
||||
zeit: { desktop: { x: 377, y: 610 }, handy: { x: 256, y: 610 } },
|
||||
buchen: { desktop: { x: 314, y: 799 }, handy: { x: 216, y: 672 } },
|
||||
weg: { desktop: { x: 470, y: 860 }, handy: { x: 340, y: 780 } },
|
||||
} as const;
|
||||
|
||||
/** Gesamt ca. 11,7 s: Auswahl (5,7 s), Mail-Hinweis, Bestätigung, Kalender halten. */
|
||||
const SCHRITTE: readonly Schritt[] = [
|
||||
{ phase: "start", ms: 900, ziel: "start" },
|
||||
{ phase: "leistung", ms: 850, ziel: "leistung" },
|
||||
{ phase: "leistung", ms: 450, ziel: "leistung", gewaehlt: 1, klick: 1 },
|
||||
{ phase: "person", ms: 750, ziel: "person", gewaehlt: 1 },
|
||||
{ phase: "person", ms: 400, ziel: "person", gewaehlt: 2, klick: 2 },
|
||||
{ phase: "zeit", ms: 750, ziel: "zeit", gewaehlt: 2 },
|
||||
{ phase: "zeit", ms: 400, ziel: "zeit", gewaehlt: 3, klick: 3 },
|
||||
{ phase: "buchen", ms: 800, ziel: "buchen", gewaehlt: 3 },
|
||||
{ phase: "buchen", ms: 400, ziel: "buchen", gewaehlt: 3, gedrueckt: true, klick: 4 },
|
||||
{ phase: "mail", ms: 1500, ziel: "weg", gewaehlt: 3, klick: 4 },
|
||||
{ phase: "bestaetigt", ms: 1300, ziel: "weg", gewaehlt: 3, klick: 4 },
|
||||
{ phase: "kalender", ms: KALENDER_MS, ziel: "weg", gewaehlt: 3, klick: 4 },
|
||||
];
|
||||
|
||||
const DAUERN = SCHRITTE.map((s) => s.ms);
|
||||
|
||||
const zustandVon = (s: Schritt, laeuft: boolean, statisch: boolean): TerminZustand => {
|
||||
const g = s.gewaehlt ?? 0;
|
||||
return {
|
||||
phase: s.phase,
|
||||
gewaehlt: { leistung: g >= 1, person: g >= 2, zeit: g >= 3 },
|
||||
gedrueckt: !!s.gedrueckt,
|
||||
zeiger: ZEIGER[s.ziel],
|
||||
zeigerSichtbar: !statisch && s.phase !== "mail" && s.phase !== "bestaetigt" && s.phase !== "kalender",
|
||||
klick: s.klick ?? 0,
|
||||
statisch,
|
||||
laeuft,
|
||||
};
|
||||
};
|
||||
|
||||
const ENDE = SCHRITTE[SCHRITTE.length - 1];
|
||||
|
||||
/** Anfangszustand (Phase „start“, nichts gewählt): die Bühne zeigt die Website damit sofort, bevor der Taktgeber geladen ist. */
|
||||
export const TERMIN_START: TerminZustand = zustandVon(SCHRITTE[0], false, false);
|
||||
|
||||
/**
|
||||
* Taktgeber der Demo. `statisch` (keine Bewegung erlaubt) zeigt immer den Endzustand „kalender“ ohne Zeiger
|
||||
* und startet keinen Timer. Mit laeuft = false steht der Ablauf auf „start“ still.
|
||||
*/
|
||||
export const useTerminAblauf = (laeuft: boolean, statisch: boolean): TerminZustand => {
|
||||
const { schritt } = useAblauf(laeuft && !statisch, DAUERN);
|
||||
if (statisch) return zustandVon(ENDE, false, true);
|
||||
return zustandVon(SCHRITTE[Math.min(schritt, SCHRITTE.length - 1)], laeuft, false);
|
||||
};
|
||||
@@ -0,0 +1,979 @@
|
||||
/*
|
||||
* Demo „Terminplaner“ (Salon Schnittstelle): Beispiel-Website, Kalender-Fenster und Mitteilung auf dem Handy.
|
||||
* Alle Klassen mit Präfix ts- (Seite) bzw. tsh- (Handy-Ebene). Feste Designpunkte: desktop 1512 x 893, handy 402 x 874
|
||||
* (oben 59 / unten 34 frei). Die Zeigerziele in ablauf.ts (ZEIGER) passen zu diesem Layout.
|
||||
* Grundzustand statisch (Endzustand); Bewegung nur transform/opacity hinter prefers-reduced-motion: no-preference.
|
||||
*/
|
||||
|
||||
.ts {
|
||||
--ts-bg: #f6f1ea;
|
||||
--ts-karte: #fffdf9;
|
||||
--ts-sand: #efe3d4;
|
||||
--ts-tinte: #1f1a17;
|
||||
--ts-grau: #6a5d53;
|
||||
--ts-linie: #e0d4c5;
|
||||
--ts-akzent: #bd4a32;
|
||||
--ts-akzent-weich: #f9e8e0;
|
||||
--ts-gruen: #2f6f55;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--ts-bg);
|
||||
color: var(--ts-tinte);
|
||||
font-family: "Instrument Sans", -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
.ts *,
|
||||
.ts *::before,
|
||||
.ts *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ts h1,
|
||||
.ts h2,
|
||||
.ts h3,
|
||||
.ts p {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts p {
|
||||
font-weight: 400;
|
||||
}
|
||||
.ts svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Kopf ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.ts-kopf {
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 96px;
|
||||
padding: 0 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--ts-linie);
|
||||
background: var(--ts-bg);
|
||||
z-index: 3;
|
||||
}
|
||||
.ts-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.ts-logo-zeichen {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
}
|
||||
.ts-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 44px;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ts-nav span:last-child {
|
||||
padding: 12px 28px;
|
||||
border-radius: 999px;
|
||||
background: var(--ts-tinte);
|
||||
color: var(--ts-bg);
|
||||
}
|
||||
|
||||
/* ── Hero ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Desktop-Anordnung: Das iPhone der Bühne überdeckt die Fläche rechts unten (ab x 1158, y 419). Darum liegt alles
|
||||
Wichtige links von x 1140 oder oberhalb von y 410: Widget links, Hero rechts oben, Kalender rechts daneben unten. */
|
||||
.ts-hero {
|
||||
position: absolute;
|
||||
left: 628px;
|
||||
top: 122px;
|
||||
width: 780px;
|
||||
z-index: 1;
|
||||
}
|
||||
.ts-augenbraue {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
font-size: 23px;
|
||||
font-weight: 600;
|
||||
color: var(--ts-akzent);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.ts-h1 {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: 12px;
|
||||
font-size: 84px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.ts-lead {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: 18px;
|
||||
max-width: 560px;
|
||||
font-size: 26px;
|
||||
line-height: 1.35;
|
||||
color: var(--ts-grau);
|
||||
}
|
||||
.ts-schere {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 590px;
|
||||
top: -12px;
|
||||
width: 224px;
|
||||
height: 224px;
|
||||
}
|
||||
|
||||
/* ── Buchungs-Widget (Optik wie embed/termine.js: Karten, Zeiten, Knopf) ─────────────── */
|
||||
|
||||
.ts-widget {
|
||||
position: absolute;
|
||||
left: 40px;
|
||||
top: 118px;
|
||||
width: 548px;
|
||||
height: 742px;
|
||||
padding: 28px;
|
||||
border-radius: 30px;
|
||||
background: var(--ts-karte);
|
||||
border: 1px solid var(--ts-linie);
|
||||
box-shadow: 0 30px 70px -30px rgb(60 40 25 / 0.35);
|
||||
z-index: 2;
|
||||
}
|
||||
.ts-auswahl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
height: 100%;
|
||||
}
|
||||
.ts-w-titel {
|
||||
font-size: 42px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.ts-schritt {
|
||||
transition: none;
|
||||
}
|
||||
.ts-schritt[data-aktiv="nein"] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ts-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-nr {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--ts-tinte);
|
||||
color: var(--ts-bg);
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-leistungen {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.ts-karte {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 84px;
|
||||
padding: 0 16px;
|
||||
border: 2px solid var(--ts-linie);
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
}
|
||||
.ts-karte-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-karte-meta {
|
||||
font-size: 22px;
|
||||
color: var(--ts-grau);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-pille {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--ts-sand);
|
||||
color: var(--ts-tinte);
|
||||
font-size: 20px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-karte[data-an="ja"],
|
||||
.ts-chip[data-an="ja"] {
|
||||
border-color: var(--ts-akzent);
|
||||
background: var(--ts-akzent-weich);
|
||||
}
|
||||
.ts-haken {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--ts-akzent);
|
||||
color: #fff;
|
||||
}
|
||||
.ts-haken svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.ts-chips {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.ts-chip {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 58px;
|
||||
border: 2px solid var(--ts-linie);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-chip[data-an="ja"] {
|
||||
background: var(--ts-akzent);
|
||||
color: #fff;
|
||||
}
|
||||
.ts-felder {
|
||||
display: grid;
|
||||
grid-template-columns: 0.8fr 1.4fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.ts-feld {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 58px;
|
||||
padding: 0 18px;
|
||||
border: 2px solid var(--ts-linie);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: #9a8d82;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ts-feld[data-gefuellt="ja"] {
|
||||
color: var(--ts-tinte);
|
||||
}
|
||||
.ts-knopf {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 64px;
|
||||
margin-top: auto;
|
||||
border-radius: 18px;
|
||||
background: #cbbfb2;
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-knopf[data-bereit="ja"] {
|
||||
background: var(--ts-akzent);
|
||||
}
|
||||
|
||||
/* Ergebnis: ersetzt die Auswahl (Hinweis zur Bestätigung, dann „bestätigt“) */
|
||||
.ts-auswahl,
|
||||
.ts-erg {
|
||||
opacity: 1;
|
||||
}
|
||||
.ts-widget[data-ergebnis="ja"] .ts-auswahl {
|
||||
opacity: 0;
|
||||
}
|
||||
.ts-erg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
border-radius: 30px;
|
||||
background: var(--ts-karte);
|
||||
opacity: 0;
|
||||
}
|
||||
.ts-erg[data-an="ja"] {
|
||||
opacity: 1;
|
||||
}
|
||||
.ts-erg-symbol {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 50%;
|
||||
background: var(--ts-akzent-weich);
|
||||
color: var(--ts-akzent);
|
||||
}
|
||||
.ts-erg-symbol svg {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
.ts-erg-symbol--ok {
|
||||
background: #dcefe5;
|
||||
color: var(--ts-gruen);
|
||||
}
|
||||
.ts-erg-titel {
|
||||
font-size: 52px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.ts-erg-text {
|
||||
font-size: 28px;
|
||||
line-height: 1.35;
|
||||
color: var(--ts-tinte);
|
||||
}
|
||||
.ts-erg-zeile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 6px;
|
||||
padding: 16px 22px;
|
||||
border-radius: 16px;
|
||||
background: var(--ts-sand);
|
||||
font-size: 23px;
|
||||
font-weight: 400;
|
||||
color: var(--ts-grau);
|
||||
}
|
||||
.ts-erg-zeile b {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: var(--ts-tinte);
|
||||
}
|
||||
|
||||
/* ── Kalender-Fenster (neutraler Stil einer Kalender-App, ohne Marken) ───────────────── */
|
||||
/* Wenige, große Einträge in Leistungsfarben: Damen = Türkis, Herren = Orange, Färben = Violett (keine Markenfarben). */
|
||||
|
||||
.ts-kal {
|
||||
--kal-kopf: 76px;
|
||||
--kal-tage: 46px;
|
||||
position: absolute;
|
||||
left: 628px;
|
||||
top: 414px;
|
||||
width: 512px;
|
||||
height: 446px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 22px;
|
||||
background: #fff;
|
||||
color: #243040;
|
||||
border: 1px solid #d6dbe3;
|
||||
box-shadow:
|
||||
0 2px 6px rgb(30 40 55 / 0.1),
|
||||
0 34px 70px -18px rgb(30 40 55 / 0.45);
|
||||
overflow: hidden;
|
||||
z-index: 4;
|
||||
}
|
||||
.ts-kal-kopf {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
height: var(--kal-kopf);
|
||||
padding: 0 22px;
|
||||
background: #f3f5f8;
|
||||
border-bottom: 1px solid #dde2e9;
|
||||
color: #1f6f6a;
|
||||
}
|
||||
.ts-kal-symbol {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: none;
|
||||
}
|
||||
.ts-kal-titel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
color: #243040;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.ts-kal-titel b {
|
||||
font-size: 25px;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-kal-titel span {
|
||||
font-size: 22px;
|
||||
color: #4a5667;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-kal-woche {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #243040;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-kal-tage {
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: 58px repeat(4, 1fr);
|
||||
height: var(--kal-tage);
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e2e6ec;
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
color: #3a4657;
|
||||
text-align: center;
|
||||
}
|
||||
.ts-kal-tage span[data-do="ja"] {
|
||||
color: #1f6f6a;
|
||||
}
|
||||
.ts-kal-raster {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 58px 1fr;
|
||||
background-image: repeating-linear-gradient(to bottom, #e6eaf0 0 1px, transparent 1px calc(100% / 6));
|
||||
}
|
||||
.ts-kal-stunden {
|
||||
position: relative;
|
||||
}
|
||||
.ts-kal-stunden span {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: calc(var(--i) * 100% / 6);
|
||||
transform: translateY(-50%);
|
||||
font-size: 19px;
|
||||
color: #556070;
|
||||
}
|
||||
.ts-kal-stunden span:first-child {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
.ts-kal-spalten {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
border-left: 1px solid #e2e6ec;
|
||||
}
|
||||
.ts-kal-spalte {
|
||||
position: relative;
|
||||
border-right: 1px solid #eef1f5;
|
||||
}
|
||||
.ts-kal-eintrag {
|
||||
--art-bg: #cfe8e4;
|
||||
--art-rand: #1f6f6a;
|
||||
--art-text: #0f3e3b;
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
top: calc(var(--von) * 100% / 360);
|
||||
height: calc(var(--dauer) * 100% / 360 - 2px);
|
||||
padding: 2px 8px;
|
||||
border-left: 5px solid var(--art-rand);
|
||||
border-radius: 8px;
|
||||
background: var(--art-bg);
|
||||
color: var(--art-text);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ts-kal-eintrag[data-art="herren"] {
|
||||
--art-bg: #f8dcc8;
|
||||
--art-rand: #c4622d;
|
||||
--art-text: #5a270c;
|
||||
}
|
||||
.ts-kal-eintrag[data-art="faerben"] {
|
||||
--art-bg: #e3dcf3;
|
||||
--art-rand: #6b5aa6;
|
||||
--art-text: #2b2150;
|
||||
}
|
||||
.ts-kal-eintrag b,
|
||||
.ts-kal-eintrag span {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
line-height: 1.12;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ts-kal-eintrag b {
|
||||
font-weight: 700;
|
||||
}
|
||||
.ts-kal-eintrag span {
|
||||
font-weight: 500;
|
||||
}
|
||||
/* kurze Einträge (30 Minuten): nur die Leistung, der Gastname hat keinen Platz */
|
||||
.ts-kal-eintrag[data-kurz="ja"] span {
|
||||
display: none;
|
||||
}
|
||||
.ts-kal-eintrag[data-neu="ja"] {
|
||||
z-index: 2;
|
||||
overflow: visible;
|
||||
border-left-color: #0f4f4b;
|
||||
background: #1f6f6a;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 5px rgb(31 111 106 / 0.32);
|
||||
}
|
||||
.ts-kal-eintrag[data-neu="ja"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -9px;
|
||||
border: 5px solid #1f6f6a;
|
||||
border-radius: 14px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Meldung im Kalender: oben im Raster, damit sie den neuen Eintrag (unten) nicht verdeckt */
|
||||
.ts-kal-info {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 66px;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
padding: 12px 18px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #cfd6df;
|
||||
border-left: 6px solid #1f6f6a;
|
||||
box-shadow: 0 18px 38px -10px rgb(30 40 55 / 0.5);
|
||||
}
|
||||
.ts-kal-info-quelle {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f6f6a;
|
||||
}
|
||||
.ts-kal-info b {
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
color: #1c2a3a;
|
||||
}
|
||||
.ts-kal-info-zeit {
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
color: #4a5667;
|
||||
}
|
||||
|
||||
/* ── Handy-Fassung (402 x 874, oben 59 / unten 34 frei) ─────────────────────────────── */
|
||||
|
||||
/* Kopf in Tinte: die Statusleiste des iPhones (weiße Schrift) liegt darüber und bleibt lesbar */
|
||||
.ts[data-variante="handy"] .ts-kopf {
|
||||
height: 115px;
|
||||
padding: 59px 20px 0;
|
||||
border-bottom: 0;
|
||||
background: var(--ts-tinte);
|
||||
color: var(--ts-bg);
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-logo {
|
||||
gap: 10px;
|
||||
font-size: 21px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-logo-zeichen {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.ts-menue {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
width: 30px;
|
||||
}
|
||||
.ts-menue i {
|
||||
display: block;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--ts-bg);
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-hero {
|
||||
left: 20px;
|
||||
top: 132px;
|
||||
width: 362px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-augenbraue {
|
||||
font-size: 15px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-h1 {
|
||||
margin-top: 6px;
|
||||
font-size: 38px;
|
||||
line-height: 1.02;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-schere {
|
||||
left: 258px;
|
||||
top: -6px;
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-widget {
|
||||
left: 12px;
|
||||
top: 244px;
|
||||
width: 378px;
|
||||
height: 464px;
|
||||
padding: 16px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-auswahl {
|
||||
gap: 10px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-w-titel {
|
||||
font-size: 23px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-daten {
|
||||
display: none;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-label {
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-nr {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-leistungen {
|
||||
gap: 8px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-karte {
|
||||
height: 62px;
|
||||
padding: 0 12px;
|
||||
gap: 1px;
|
||||
border-width: 1.5px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-karte-name {
|
||||
gap: 6px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-karte-meta {
|
||||
font-size: 15px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-pille {
|
||||
padding: 0 6px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-haken {
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-haken svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-chips {
|
||||
gap: 8px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-chip {
|
||||
height: 40px;
|
||||
border-width: 1.5px;
|
||||
border-radius: 12px;
|
||||
font-size: 17px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-knopf {
|
||||
height: 50px;
|
||||
border-radius: 14px;
|
||||
font-size: 19px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-erg {
|
||||
padding: 22px;
|
||||
gap: 12px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-erg-symbol {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-erg-symbol svg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-erg-titel {
|
||||
font-size: 30px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-erg-text {
|
||||
font-size: 17px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-erg-zeile {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.ts[data-variante="handy"] .ts-erg-zeile b {
|
||||
font-size: 17px;
|
||||
}
|
||||
/* Handy: kleine Tagesliste des Kalender-Abos (Do 8. Okt.) unter dem Widget, die gebuchte Zeile kommt nach der Buchung dazu */
|
||||
.ts-mini {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
top: 722px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
height: 112px;
|
||||
padding: 10px 14px 0;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
border: 1px solid #d6dbe3;
|
||||
box-shadow: 0 12px 28px -10px rgb(30 40 55 / 0.4);
|
||||
color: #1c2a3a;
|
||||
}
|
||||
.ts-mini-kopf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 24px;
|
||||
color: #1f6f6a;
|
||||
}
|
||||
.ts-mini-kopf .ts-kal-symbol {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.ts-mini-kopf b {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #1c2a3a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-mini-kopf span {
|
||||
font-size: 15px;
|
||||
color: #4a5667;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-mini-zeile {
|
||||
--art-rand: #1f6f6a;
|
||||
--art-bg: #cfe8e4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 22px;
|
||||
padding: 0 8px 0 0;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ts-mini-zeile i {
|
||||
align-self: stretch;
|
||||
width: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--art-rand);
|
||||
}
|
||||
.ts-mini-zeile span {
|
||||
width: 42px;
|
||||
color: #4a5667;
|
||||
}
|
||||
.ts-mini-zeile b {
|
||||
font-weight: 650;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ts-mini-zeile[data-art="herren"] {
|
||||
--art-rand: #c4622d;
|
||||
}
|
||||
.ts-mini-zeile[data-art="faerben"] {
|
||||
--art-rand: #6b5aa6;
|
||||
}
|
||||
.ts-mini-zeile--neu {
|
||||
opacity: 0;
|
||||
}
|
||||
.ts-mini-zeile--neu[data-an="ja"] {
|
||||
opacity: 1;
|
||||
background: #1f6f6a;
|
||||
color: #fff;
|
||||
}
|
||||
.ts-mini-zeile--neu[data-an="ja"] span {
|
||||
color: #d5ece9;
|
||||
}
|
||||
|
||||
/* ── Mitteilung im Handy der Bühne (Live-Ebene über dem Portal-Bild) ─────────────────── */
|
||||
|
||||
.tsh {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
.tsh-mitteilung {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
top: 67px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 13px 15px 14px;
|
||||
border-radius: 24px;
|
||||
background: rgb(247 247 249 / 0.97);
|
||||
box-shadow:
|
||||
0 1px 2px rgb(0 0 0 / 0.2),
|
||||
0 14px 32px rgb(0 0 0 / 0.32);
|
||||
color: #1c1c1e;
|
||||
}
|
||||
.tsh-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
background: #1d1d1f;
|
||||
color: #fff;
|
||||
}
|
||||
.tsh-icon svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
.tsh-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.tsh-zeile {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
font-size: 15px;
|
||||
}
|
||||
.tsh-zeile b {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tsh-zeile span {
|
||||
color: #6c6c70;
|
||||
}
|
||||
.tsh-betreff {
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* ── Bewegung (nur transform/opacity, nur ohne reduzierte Bewegung) ──────────────────── */
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.ts-auswahl,
|
||||
.ts-erg {
|
||||
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.ts-erg {
|
||||
transform: translateY(10px);
|
||||
transition:
|
||||
opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.ts-erg[data-an="ja"] {
|
||||
transform: none;
|
||||
}
|
||||
.ts-schritt {
|
||||
transition: opacity 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.ts-knopf[data-gedrueckt="ja"] {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.ts-knopf {
|
||||
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
/* Der Eintrag erscheint weich, danach ein kurzer Hervorhebungsring (zweimal), Dauer der Phase 3,3 s (KALENDER_MS) */
|
||||
.ts-kal-eintrag[data-neu="ja"] {
|
||||
animation: ts-neu 600ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
.ts-kal-eintrag[data-neu="ja"]::after {
|
||||
animation: ts-ring 1100ms cubic-bezier(0.2, 0.8, 0.2, 1) 500ms 2 both;
|
||||
}
|
||||
.ts-kal-info {
|
||||
animation: ts-info 3300ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
.ts-mini-zeile--neu[data-an="ja"] {
|
||||
animation: ts-mini-ein 500ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
.tsh-mitteilung {
|
||||
animation: tsh-rein 3300ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
}
|
||||
@keyframes ts-neu {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes ts-ring {
|
||||
from {
|
||||
opacity: 0.9;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(1.22, 1.5);
|
||||
}
|
||||
}
|
||||
@keyframes ts-info {
|
||||
0%,
|
||||
12% {
|
||||
opacity: 0;
|
||||
transform: translateX(-16px);
|
||||
}
|
||||
26%,
|
||||
86% {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes ts-mini-ein {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes tsh-rein {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-150%);
|
||||
}
|
||||
12%,
|
||||
88% {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@
|
||||
*/
|
||||
|
||||
.kp {
|
||||
/* Länge eines Funktionsschritts auf der Bühne; fünf davon plus der letzte Schritt ergeben die Scrollstrecke */
|
||||
/* Länge eines Funktionsschritts auf der Bühne; die normalen und die längeren Demo-Schritte (der Ablauf soll zu sehen
|
||||
sein) plus der letzte Schritt ergeben die Scrollstrecke */
|
||||
--kp-s: 45svh;
|
||||
--kp-d: 70svh;
|
||||
--kp-ende: 120svh;
|
||||
--kp-oben: calc(var(--header-h) + 24px);
|
||||
--kp-buehne-h: calc(100svh - var(--header-h) - 48px);
|
||||
@@ -16,6 +18,8 @@
|
||||
.kp {
|
||||
/* nichts bewegt sich im letzten Schritt, also braucht er keine lange Strecke */
|
||||
--kp-ende: 50svh;
|
||||
/* die Demos zeigen ihren Endzustand und brauchen keine lange Strecke */
|
||||
--kp-d: 45svh;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +37,10 @@
|
||||
|
||||
.kp-huelle {
|
||||
position: relative;
|
||||
height: calc(var(--kp-buehne-h) + 5 * var(--kp-s) + var(--kp-ende));
|
||||
/* --kp-nn und --kp-nd (Zahl der normalen und der Demo-Schritte vor "Als App") setzt die Bühne inline */
|
||||
height: calc(
|
||||
var(--kp-buehne-h) + var(--kp-nn, 4) * var(--kp-s) + var(--kp-nd, 3) * var(--kp-d) + var(--kp-ende)
|
||||
);
|
||||
}
|
||||
|
||||
/* unsichtbare Spur aus Markern: der IntersectionObserver beobachtet sie, die Bühne klebt darüber */
|
||||
@@ -48,6 +55,9 @@
|
||||
flex: 0 0 var(--kp-s);
|
||||
}
|
||||
/* Die erste Marke reicht bis zur Mitte des Bildschirms, wenn die Bühne anklebt: Schritt 1 ist beim Ankommen aktiv */
|
||||
.kp-marker--demo {
|
||||
flex-basis: var(--kp-d);
|
||||
}
|
||||
.kp-marker:first-child {
|
||||
flex-basis: calc(50svh - var(--kp-oben) + var(--kp-s));
|
||||
}
|
||||
@@ -74,14 +84,14 @@
|
||||
.kp-liste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 3px;
|
||||
}
|
||||
.kp-eintrag {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 10px 0;
|
||||
padding: 8px 0;
|
||||
text-align: left;
|
||||
color: var(--fg-muted);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
@@ -108,7 +118,7 @@
|
||||
.kp-eintrag__punkt {
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
top: 19px;
|
||||
top: 17px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
@@ -150,14 +160,42 @@
|
||||
.kp-eintrag__punkt {
|
||||
top: 15px;
|
||||
}
|
||||
.kp-gruppe[data-erste="ja"] {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.kp-gruppe__titel {
|
||||
padding-top: 6px;
|
||||
}
|
||||
}
|
||||
/* sehr niedrige Fenster: nur die aktive Funktion zeigt ihre Unterschrift */
|
||||
@media (max-height: 620px) {
|
||||
/* sehr niedrige Fenster: nur die aktive Funktion zeigt ihre Unterschrift (acht 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 {
|
||||
align-self: start;
|
||||
}
|
||||
.kp-eintrag:not([aria-current="step"]) .kp-eintrag__text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Gruppe "Funktionen zum Dazubuchen": die drei Demo-Stationen mit kleiner Überschrift darüber */
|
||||
.kp-gruppe[data-erste="ja"] {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.kp-gruppe__titel {
|
||||
margin: 0 0 2px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--line-strong);
|
||||
font-size: var(--fs-small);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.kp-gruppe .kp-eintrag__titel {
|
||||
font-size: 1.1875rem;
|
||||
}
|
||||
|
||||
/* Geräte: Desktop-Fenster mit überlappendem Handy-Rahmen unten rechts */
|
||||
.kp-geraete {
|
||||
/* Breite so, dass die Geräte nie höher werden als die Bühne (Seitenverhältnis 100 : 95) */
|
||||
@@ -182,6 +220,77 @@
|
||||
width: 28.2cqw;
|
||||
}
|
||||
|
||||
/* Unter dem MacBook, links neben dem iPhone: Diagnose (nur bei der Website-Analyse) und Hinweis aufs erfundene Beispiel.
|
||||
Die Diagnose hat in Seitenmaßstab eine feste Höhe, damit beim Wechsel nichts springt. */
|
||||
.kp-unter {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
/* direkt unter dem MacBook (dessen Höhe ist 62,6 % von cqw); wo die Geräte schmal sind, ragt der Block
|
||||
in den freien Platz unter den Geräten, statt das MacBook zu überdecken */
|
||||
top: calc(62.6cqw + 8px);
|
||||
width: min(560px, calc(70cqw - 8px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
/* Karten unter dem MacBook: Diagnose und Hinweise liegen übereinander in einer Gitterzelle, nur die der aktiven Station
|
||||
ist sichtbar (die Zelle hat die Höhe der Diagnose, beim Wechsel springt nichts) */
|
||||
.kp-karten {
|
||||
display: grid;
|
||||
}
|
||||
.kp-diagnose,
|
||||
.kp-hinweis {
|
||||
grid-area: 1 / 1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.kp-diagnose[data-an="ja"],
|
||||
.kp-hinweis[data-an="ja"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* Hinweiskarte im Stil der Diagnose (gleiche Linie, gleiches Padding) */
|
||||
.kp-hinweis {
|
||||
align-self: start;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 12px 16px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-alt);
|
||||
color: var(--fg);
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.kp-beispiele {
|
||||
display: grid;
|
||||
}
|
||||
.kp-beispiel {
|
||||
grid-area: 1 / 1;
|
||||
margin: 0;
|
||||
font-size: var(--fs-small);
|
||||
line-height: 1.3;
|
||||
color: var(--fg-muted);
|
||||
opacity: 0;
|
||||
}
|
||||
.kp-beispiel[data-an="ja"] {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.kp-diagnose,
|
||||
.kp-hinweis,
|
||||
.kp-beispiel {
|
||||
transition: opacity var(--dur-enter) var(--ease);
|
||||
}
|
||||
}
|
||||
|
||||
/* Platzhalter, solange der Chunk einer Beispiel-Website lädt: Seitenfarbe statt schwarzer Fläche */
|
||||
.kp-platzhalter {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Ebenen, die per Überblendung wechseln (nur die aktive ist sichtbar) */
|
||||
.kp-ebenen {
|
||||
position: absolute;
|
||||
@@ -191,9 +300,12 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
/* nur die sichtbare Ebene reagiert auf die Maus (Heatmap-Website) */
|
||||
pointer-events: none;
|
||||
}
|
||||
.kp-lage.is-aktiv {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.kp-lage img {
|
||||
display: block;
|
||||
@@ -264,6 +376,29 @@
|
||||
max-width: 26ch;
|
||||
}
|
||||
|
||||
/* Demo-Karten: die Website läuft im Handy, darunter Hinweis aufs erfundene Beispiel (Heatmap: zusätzlich die Diagnose) */
|
||||
.kp-karte__geraet {
|
||||
width: min(100%, 250px);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.kp-karte__geraet .kp-karte__handy {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.kp-karte__beispiel {
|
||||
margin: 0.6rem 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: var(--fg-muted);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.kp-karte__diagnose {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin-top: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* "Als App": der lange Screenshot schiebt sich langsam nach oben, solange die Karte zu sehen ist */
|
||||
/* (.kp-lang liegt absolut im Schirm; was unten herausragt, schneidet der Rahmen ab) */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, within, act, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { useHeatmapAblauf, AUFBAU_DAUER } from "@/components/home/demos/heatmap/ablauf";
|
||||
import HeatmapSeite from "@/components/home/demos/heatmap/HeatmapSeite";
|
||||
import HeatmapDiagnose from "@/components/home/demos/heatmap/HeatmapDiagnose";
|
||||
import {
|
||||
ZONEN,
|
||||
SUMME_KLICKS,
|
||||
TOP_ZONE,
|
||||
anteil,
|
||||
rangliste,
|
||||
ersatzRechtecke,
|
||||
ZONEN_LAYOUT,
|
||||
} from "@/components/home/demos/heatmap/zonen";
|
||||
|
||||
// Spezifikation 3.2 (Website-Analyse, Boulderhalle Überhang)
|
||||
|
||||
/** Zeigt Website, Handy-Fassung und Diagnose mit einem gemeinsamen Zustand. */
|
||||
const Probe = ({ laeuft, statisch }: { laeuft: boolean; statisch: boolean }) => {
|
||||
const z = useHeatmapAblauf(laeuft, statisch);
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="desktop">
|
||||
<HeatmapSeite zustand={z} variante="desktop" />
|
||||
</div>
|
||||
<div data-testid="handy">
|
||||
<HeatmapSeite zustand={z} variante="handy" />
|
||||
</div>
|
||||
<HeatmapDiagnose zustand={z} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Spult die Uhr in kleinen Schritten vor: jeder Schritt des Ablaufs setzt seinen nächsten Timer erst nach dem Rendern. */
|
||||
const vorspulen = (ms: number) => {
|
||||
for (let t = 0; t < ms; t += 50) {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** Spult in kleinen Schritten, bis die Phase erreicht ist (höchstens 30 s). Gibt die vergangene Zeit zurück. */
|
||||
const spuleBis = (wurzel: HTMLElement, phase: string) => {
|
||||
let vergangen = 0;
|
||||
while (wurzel.getAttribute("data-phase") !== phase && vergangen < 30_000) {
|
||||
vorspulen(50);
|
||||
vergangen += 50;
|
||||
}
|
||||
return vergangen;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// jsdom hat keine 2D-Leinwand; die Zeichnung wird übersprungen
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Zonen und Rangliste", () => {
|
||||
it("die Klicks summieren sich auf 1.284, mit 8 bis 10 Zonen", () => {
|
||||
expect(ZONEN.reduce((s, z) => s + z.klicks, 0)).toBe(1284);
|
||||
expect(SUMME_KLICKS).toBe(1284);
|
||||
expect(ZONEN.length).toBeGreaterThanOrEqual(8);
|
||||
expect(ZONEN.length).toBeLessThanOrEqual(10);
|
||||
expect(new Set(ZONEN.map((z) => z.id)).size).toBe(ZONEN.length);
|
||||
});
|
||||
|
||||
it("die Rangliste ist absteigend, die Top-Zone ist Tageskarte kaufen mit 31 %", () => {
|
||||
const liste = rangliste();
|
||||
for (let i = 1; i < liste.length; i++) expect(liste[i - 1].klicks).toBeGreaterThanOrEqual(liste[i].klicks);
|
||||
expect(TOP_ZONE.name).toBe("Tageskarte kaufen");
|
||||
expect(TOP_ZONE.klicks).toBe(402);
|
||||
expect(anteil(TOP_ZONE)).toBe(31);
|
||||
expect(rangliste(4)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("die Prozente sind ganze Zahlen und ergeben ungefähr 100", () => {
|
||||
const summe = ZONEN.reduce((s, z) => s + anteil(z), 0);
|
||||
for (const z of ZONEN) expect(Number.isInteger(anteil(z))).toBe(true);
|
||||
expect(summe).toBeGreaterThanOrEqual(98);
|
||||
expect(summe).toBeLessThanOrEqual(102);
|
||||
});
|
||||
|
||||
it("nur das Routenbild ist ein toter Klick, jede Zone hat einen Satz ohne Erfolgsversprechen", () => {
|
||||
expect(ZONEN.filter((z) => z.tot).map((z) => z.id)).toEqual(["routenbild"]);
|
||||
for (const z of ZONEN) {
|
||||
expect(z.satz.length).toBeGreaterThan(10);
|
||||
expect(z.satz).not.toMatch(/Umsatz|mehr Kunden|Erfolg|garantiert|steiger|verdoppel/i);
|
||||
}
|
||||
expect(TOP_ZONE.satz).toBe("Am meisten geklickt: „Tageskarte kaufen“ – 31\u00a0% aller Klicks.");
|
||||
});
|
||||
|
||||
it("die Ersatz-Rechtecke liegen innerhalb der Fläche", () => {
|
||||
for (const variante of ["desktop", "handy"] as const) {
|
||||
const rechtecke = ersatzRechtecke(variante);
|
||||
expect(Object.keys(rechtecke).sort()).toEqual(ZONEN.map((z) => z.id).sort());
|
||||
for (const r of Object.values(rechtecke)) {
|
||||
expect(r.x).toBeGreaterThanOrEqual(0);
|
||||
expect(r.y).toBeGreaterThanOrEqual(0);
|
||||
expect(r.x + r.b).toBeLessThanOrEqual(1);
|
||||
expect(r.y + r.h).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lage der Zonen auf der Bühne", () => {
|
||||
it("keine Zone liegt unter dem iPhone der Bühne (es deckt die Fläche ab x 1158 und y 419)", () => {
|
||||
for (const [id, r] of Object.entries(ZONEN_LAYOUT.desktop)) {
|
||||
const links = r.x + r.b <= 1150;
|
||||
const oben = r.y + r.h <= 410;
|
||||
expect(links || oben, `${id} liegt unter dem Handy`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("die Diagnose hat Legende (wenig bis viel) und die Satzlänge des Routenbilds passt in vier bis fünf Zeilen", () => {
|
||||
const { container } = render(<HeatmapDiagnose />);
|
||||
expect(container.querySelector(".hmd__legende")).toHaveTextContent("wenig");
|
||||
expect(container.querySelector(".hmd__legende")).toHaveTextContent("viel");
|
||||
const routen = ZONEN.find((z) => z.id === "routenbild")!;
|
||||
expect(routen.satz.length).toBeLessThan(95);
|
||||
expect(routen.satz).toMatch(/^Auffällig: 12\u00a0% klicken auf das Routenbild\. Es ist kein Link/);
|
||||
});
|
||||
|
||||
it("Website und Diagnose stehen ohne Zustand im Anfangszustand da (Taktgeber lädt noch)", () => {
|
||||
const { container } = render(<HeatmapSeite variante="desktop" />);
|
||||
expect(container.firstElementChild).toHaveAttribute("data-phase", "aufbau");
|
||||
expect(container.querySelector(".hm-rahmen")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Statischer Endzustand (ohne Bewegung)", () => {
|
||||
it("zeigt die volle Heatmap, keinen Zeiger und wählt die meistgeklickte Zone", () => {
|
||||
const { container } = render(<Probe laeuft={false} statisch />);
|
||||
for (const wurzel of container.querySelectorAll(".hm-seite")) {
|
||||
expect(wurzel).toHaveAttribute("data-phase", "voll");
|
||||
expect(wurzel.querySelector(".dz-zeiger")).toBeNull();
|
||||
expect(wurzel.querySelector(".hm-heat")).toHaveAttribute("data-anteil", "1284");
|
||||
}
|
||||
const satz = container.querySelector("[aria-live='polite']") as HTMLElement;
|
||||
expect(satz).toHaveTextContent("Am meisten geklickt: „Tageskarte kaufen“ – 31 % aller Klicks.");
|
||||
expect(screen.getByRole("button", { name: /Tageskarte kaufen/ })).toHaveAttribute("aria-pressed", "true");
|
||||
// Rahmen und Schildchen an der Zone auf der Website
|
||||
expect(screen.getAllByText("Tageskarte kaufen · 402 Klicks · 31 %").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("die Diagnose zeigt Kopf, Summe und die vier meistgeklickten Zonen", () => {
|
||||
render(<Probe laeuft={false} statisch />);
|
||||
expect(screen.getByText("Diagnose")).toBeInTheDocument();
|
||||
expect(screen.getByText(/letzte 30 Tage · Beispieldaten/)).toBeInTheDocument();
|
||||
expect(screen.getByText("1.284 Klicks")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button")).toHaveLength(4);
|
||||
expect(screen.getByText("toter Klick")).toBeInTheDocument();
|
||||
expect(screen.getByText("31 %")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Auswahl über die Rangliste", () => {
|
||||
it("ein Klick auf eine Zeile wählt die Zone, der Satz folgt, der Rahmen erscheint auf der Website", () => {
|
||||
const { container } = render(<Probe laeuft={false} statisch />);
|
||||
const routenbild = screen.getByRole("button", { name: /Routenbild/ });
|
||||
expect(routenbild).toHaveAttribute("aria-pressed", "false");
|
||||
fireEvent.click(routenbild);
|
||||
expect(routenbild).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: /Tageskarte kaufen/ })).toHaveAttribute("aria-pressed", "false");
|
||||
const satz = container.querySelector("[aria-live='polite']") as HTMLElement;
|
||||
expect(satz).toHaveTextContent(/Auffällig: 12 % klicken auf das Routenbild\. Es ist kein Link/);
|
||||
const desktop = screen.getByTestId("desktop");
|
||||
expect(within(desktop).getByText(/Routenbild · 154 Klicks · 12 %/)).toBeInTheDocument();
|
||||
expect(desktop.querySelector(".hm-rahmen")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Tippen auf eine Zone der Website wählt sie, nochmal Tippen hebt auf (Handy)", () => {
|
||||
const { container } = render(<Probe laeuft={false} statisch={false} />);
|
||||
const handy = screen.getByTestId("handy");
|
||||
const preise = handy.querySelector('[data-zone="preise"]') as HTMLElement;
|
||||
fireEvent.click(preise);
|
||||
expect(container.querySelector("[aria-live='polite']")).toHaveTextContent("9 % aller Klicks");
|
||||
expect(handy.querySelector(".hm-rahmen")).not.toBeNull();
|
||||
fireEvent.click(preise);
|
||||
expect(handy.querySelector(".hm-rahmen")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Website ist reine Dekoration", () => {
|
||||
it.each(["desktop", "handy"] as const)("%s: aria-hidden, data-phase, Zonen und nichts Fokussierbares", (variante) => {
|
||||
render(<Probe laeuft={false} statisch />);
|
||||
const wurzel = screen.getByTestId(variante).querySelector(".hm-seite") as HTMLElement;
|
||||
expect(wurzel).toHaveAttribute("aria-hidden", "true");
|
||||
expect(wurzel).toHaveAttribute("data-phase");
|
||||
expect(wurzel).toHaveAttribute("data-variante", variante);
|
||||
const fokussierbar = wurzel.querySelectorAll(
|
||||
"a, button, input, select, textarea, iframe, [tabindex], [contenteditable]",
|
||||
);
|
||||
expect(fokussierbar).toHaveLength(0);
|
||||
for (const z of ZONEN) expect(wurzel.querySelector(`[data-zone="${z.id}"]`)).not.toBeNull();
|
||||
expect(wurzel.querySelector("canvas")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("enthält nur selbst gezeichnete Grafiken: keine Bilder, keine Webadressen", () => {
|
||||
const { container } = render(<Probe laeuft={false} statisch />);
|
||||
expect(container.querySelectorAll("img, video, iframe, link, script")).toHaveLength(0);
|
||||
for (const datei of ["HeatmapSeite.tsx", "HeatmapDiagnose.tsx", "heatmap.css", "ablauf.ts", "zonen.ts"]) {
|
||||
const text = readFileSync(`${process.cwd()}/src/components/home/demos/heatmap/${datei}`, "utf8");
|
||||
expect(text).not.toMatch(/https?:\/\/(?!www\.w3\.org)/);
|
||||
}
|
||||
});
|
||||
|
||||
it("ablauf.ts und zonen.ts importieren kein CSS", () => {
|
||||
for (const datei of ["ablauf.ts", "zonen.ts"]) {
|
||||
const text = readFileSync(`${process.cwd()}/src/components/home/demos/heatmap/${datei}`, "utf8");
|
||||
expect(text).not.toMatch(/import\s+["'][^"']+\.css["']/);
|
||||
expect(text).not.toMatch(/from\s+["'][^"']*(HeatmapSeite|HeatmapDiagnose)/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ablauf", () => {
|
||||
it("läuft nichts, solange laeuft=false ist: keine Timer, leere Heatmap, kein sichtbarer Zeiger", () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = render(<Probe laeuft={false} statisch={false} />);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
vorspulen(30_000);
|
||||
const wurzel = container.querySelector(".hm-desktop") as HTMLElement;
|
||||
expect(wurzel).toHaveAttribute("data-phase", "aufbau");
|
||||
expect(wurzel).toHaveAttribute("data-laeuft", "nein");
|
||||
expect(wurzel.querySelector(".hm-heat")).toHaveAttribute("data-anteil", "0");
|
||||
expect(wurzel.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "nein");
|
||||
});
|
||||
|
||||
it("baut sich in etwa 10 Sekunden auf, hält, blendet aus und beginnt von vorn", () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = render(<Probe laeuft statisch={false} />);
|
||||
const wurzel = container.querySelector(".hm-desktop") as HTMLElement;
|
||||
const heat = () => Number(wurzel.querySelector(".hm-heat")?.getAttribute("data-anteil"));
|
||||
expect(AUFBAU_DAUER).toBeGreaterThan(9_000);
|
||||
expect(AUFBAU_DAUER).toBeLessThan(11_500);
|
||||
expect(wurzel).toHaveAttribute("data-laeuft", "ja");
|
||||
expect(wurzel.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "ja");
|
||||
|
||||
vorspulen(3_000);
|
||||
const frueh = heat();
|
||||
expect(frueh).toBeGreaterThan(0);
|
||||
expect(frueh).toBeLessThan(1284);
|
||||
expect(wurzel).toHaveAttribute("data-phase", "aufbau");
|
||||
|
||||
const bisVoll = spuleBis(wurzel, "voll") + 3_000;
|
||||
expect(bisVoll).toBeGreaterThan(AUFBAU_DAUER - 200);
|
||||
expect(bisVoll).toBeLessThan(AUFBAU_DAUER + 1_500);
|
||||
expect(wurzel).toHaveAttribute("data-phase", "voll");
|
||||
expect(heat()).toBe(1284);
|
||||
expect(wurzel.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "nein");
|
||||
|
||||
const halten = spuleBis(wurzel, "ausblenden");
|
||||
expect(halten).toBeGreaterThanOrEqual(5_900);
|
||||
expect(halten).toBeLessThanOrEqual(6_300);
|
||||
spuleBis(wurzel, "aufbau");
|
||||
expect(wurzel).toHaveAttribute("data-phase", "aufbau");
|
||||
expect(heat()).toBe(0);
|
||||
});
|
||||
|
||||
it("endet laeuft mitten im Aufbau, stehen alle Timer still und der Ablauf beginnt später von vorn", () => {
|
||||
vi.useFakeTimers();
|
||||
const { container, rerender } = render(<Probe laeuft statisch={false} />);
|
||||
vorspulen(4_000);
|
||||
const wurzel = container.querySelector(".hm-desktop") as HTMLElement;
|
||||
expect(Number(wurzel.querySelector(".hm-heat")?.getAttribute("data-anteil"))).toBeGreaterThan(0);
|
||||
rerender(<Probe laeuft={false} statisch={false} />);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(wurzel.querySelector(".hm-heat")).toHaveAttribute("data-anteil", "0");
|
||||
});
|
||||
|
||||
it("Maus über der Website: Zeiger aus, Heatmap voll, Ablauf pausiert; danach geht es von dort aus weiter", () => {
|
||||
vi.useFakeTimers();
|
||||
let z: ReturnType<typeof useHeatmapAblauf> | undefined;
|
||||
const Hol = () => {
|
||||
z = useHeatmapAblauf(true, false);
|
||||
return null;
|
||||
};
|
||||
render(<Hol />);
|
||||
vorspulen(2_000);
|
||||
expect(z?.phase).toBe("aufbau");
|
||||
act(() => z?.setzeMaus(true));
|
||||
expect(z?.maus).toBe(true);
|
||||
expect(z?.phase).toBe("voll");
|
||||
expect(z?.punkte).toBe(1284);
|
||||
expect(z?.zeiger.desktop.sichtbar).toBe(false);
|
||||
expect(z?.laeuft).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
vorspulen(60_000);
|
||||
expect(z?.phase).toBe("voll");
|
||||
act(() => z?.setzeMaus(false));
|
||||
expect(z?.phase).toBe("voll");
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, renderHook, act, cleanup } from "@testing-library/react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import PinnwandSeite from "@/components/home/demos/pinnwand/PinnwandSeite";
|
||||
import PinnwandHandy from "@/components/home/demos/pinnwand/PinnwandHandy";
|
||||
import { usePinnwandAblauf, PINNWAND_RUNDE_MS, VEROEFFENTLICHEN_PUNKT, type PinnwandZustand } from "@/components/home/demos/pinnwand/ablauf";
|
||||
|
||||
// Spezifikation 2026-10-04-funktionen-demos, Abschnitt 3.3 (News-Pinnwand, Ortsgemeinde Musterbach)
|
||||
|
||||
const ENDE: PinnwandZustand = { phase: "haengt", klick: 0, runde: 0, statisch: true };
|
||||
const WARTEN: PinnwandZustand = { phase: "warten", klick: 0, runde: 0, statisch: false };
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("PinnwandSeite", () => {
|
||||
for (const variante of ["desktop", "handy"] as const) {
|
||||
it(`zeigt im Endzustand den neuen Zettel mit Plakette „Neu“ (${variante})`, () => {
|
||||
const { container } = render(<PinnwandSeite zustand={ENDE} variante={variante} />);
|
||||
const wurzel = container.firstElementChild as HTMLElement;
|
||||
expect(wurzel).toHaveAttribute("data-phase", "haengt");
|
||||
expect(wurzel).toHaveAttribute("data-variante", variante);
|
||||
expect(wurzel).toHaveAttribute("aria-hidden", "true");
|
||||
const neu = container.querySelector('[data-zettel="kerwe"]') as HTMLElement;
|
||||
expect(neu).not.toBeNull();
|
||||
expect(neu.textContent).toContain("Kerwe 2026");
|
||||
expect(neu.textContent).toContain("Freitag bis Montag auf dem Dorfplatz. Fahrgeschäfte, Musik und Weinstand der Feuerwehr.");
|
||||
expect(neu.querySelector(".pw-plakette")?.textContent).toBe("Neu");
|
||||
expect(neu.querySelector(".pw-foto svg")).not.toBeNull();
|
||||
});
|
||||
|
||||
it(`enthält keine fokussierbaren Elemente, keine Links und keine Bilddateien (${variante})`, () => {
|
||||
const { container } = render(<PinnwandSeite zustand={ENDE} variante={variante} />);
|
||||
expect(
|
||||
container.querySelectorAll("a, button, input, select, textarea, iframe, img, video, [tabindex], [contenteditable]"),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
|
||||
it("trägt den Ort und die Gemeinde in der Desktop-Fassung", () => {
|
||||
const { container } = render(<PinnwandSeite zustand={ENDE} variante="desktop" />);
|
||||
const text = container.textContent ?? "";
|
||||
for (const wort of ["Ortsgemeinde", "Musterbach", "Rathaus", "Leben in Musterbach", "Veranstaltungen", "Pinnwand", "Neues aus Musterbach"]) {
|
||||
expect(text).toContain(wort);
|
||||
}
|
||||
for (const alt of ["Hauptstraße ab 13.10. halbseitig gesperrt", "Neue Bücherzelle", "Sprechstunde", "Grünschnitt-Sammelstelle"]) {
|
||||
expect(text).toContain(alt);
|
||||
}
|
||||
// mindestens ein Zettel mit gezeichnetem „Foto“ außer dem neuen
|
||||
expect(container.querySelectorAll(".pw-foto svg").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("gibt den Zustand über data-phase weiter", () => {
|
||||
const { container, rerender } = render(<PinnwandSeite zustand={WARTEN} variante="desktop" />);
|
||||
expect(container.firstElementChild).toHaveAttribute("data-phase", "warten");
|
||||
rerender(<PinnwandSeite zustand={{ ...WARTEN, phase: "faellt" }} variante="desktop" />);
|
||||
expect(container.firstElementChild).toHaveAttribute("data-phase", "faellt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PinnwandHandy", () => {
|
||||
it("ist eine durchsichtige, unbedienbare Ebene mit Finger nur in Phase „tippen“", () => {
|
||||
const { container, rerender } = render(<PinnwandHandy zustand={WARTEN} />);
|
||||
const ebene = container.firstElementChild as HTMLElement;
|
||||
expect(ebene).toHaveAttribute("aria-hidden", "true");
|
||||
expect(ebene.style.pointerEvents).toBe("none");
|
||||
expect(ebene.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "nein");
|
||||
|
||||
rerender(<PinnwandHandy zustand={{ ...WARTEN, phase: "tippen", klick: 1 }} />);
|
||||
const zeiger = container.querySelector(".dz-zeiger") as HTMLElement;
|
||||
expect(zeiger).toHaveAttribute("data-art", "finger");
|
||||
expect(zeiger).toHaveAttribute("data-sichtbar", "ja");
|
||||
expect(zeiger.style.transform).toBe(`translate(${VEROEFFENTLICHEN_PUNKT.x}px, ${VEROEFFENTLICHEN_PUNKT.y}px)`);
|
||||
expect(container.querySelector(".dz-welle")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("nimmt eine eigene Position für „Veröffentlichen“ an", () => {
|
||||
const { container } = render(<PinnwandHandy zustand={{ ...WARTEN, phase: "tippen" }} punkt={{ x: 120, y: 640 }} />);
|
||||
expect((container.querySelector(".dz-zeiger") as HTMLElement).style.transform).toBe("translate(120px, 640px)");
|
||||
});
|
||||
});
|
||||
|
||||
/** Zeit in kleinen Schritten vorspulen: jeder Schritt der Demo setzt seinen Timer erst nach dem Rendern. */
|
||||
const vor = (ms: number) => {
|
||||
for (let rest = ms; rest > 0; rest -= 50) {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(Math.min(50, rest));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
describe("usePinnwandAblauf", () => {
|
||||
const phasen = (laeuft: boolean, statisch: boolean) =>
|
||||
renderHook(({ l, s }) => usePinnwandAblauf(l, s), { initialProps: { l: laeuft, s: statisch } });
|
||||
|
||||
it("geht die Phasen warten, tippen, faellt, haengt durch und beginnt dann von vorn", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = phasen(true, false);
|
||||
const folge: string[] = [result.current.phase];
|
||||
const schritt = (ms: number) => {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(ms);
|
||||
});
|
||||
if (folge[folge.length - 1] !== result.current.phase) folge.push(result.current.phase);
|
||||
};
|
||||
// in kleinen Schritten bis über das Ende des ersten Durchlaufs hinaus
|
||||
for (let t = 0; t < PINNWAND_RUNDE_MS + 300; t += 50) schritt(50);
|
||||
expect(folge).toEqual(["warten", "tippen", "faellt", "haengt", "warten"]);
|
||||
expect(result.current.runde).toBe(1);
|
||||
});
|
||||
|
||||
it("hat die Dauern der Spezifikation (warten ca. 1,5 s, fällt ca. 1,2 s, hängt ca. 5 s)", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = phasen(true, false);
|
||||
expect(result.current.phase).toBe("warten");
|
||||
vor(1499);
|
||||
expect(result.current.phase).toBe("warten");
|
||||
vor(1);
|
||||
expect(result.current.phase).toBe("tippen");
|
||||
// Tippen dauert 1450 ms insgesamt, danach fällt der Zettel 1200 ms
|
||||
vor(1450);
|
||||
expect(result.current.phase).toBe("faellt");
|
||||
vor(1199);
|
||||
expect(result.current.phase).toBe("faellt");
|
||||
vor(1);
|
||||
expect(result.current.phase).toBe("haengt");
|
||||
vor(4999);
|
||||
expect(result.current.phase).toBe("haengt");
|
||||
vor(1);
|
||||
expect(result.current.phase).toBe("warten");
|
||||
});
|
||||
|
||||
it("zählt den Tipp: klick ist 0 vor dem Tipp und wächst mit jedem Durchlauf", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = phasen(true, false);
|
||||
expect(result.current.klick).toBe(0);
|
||||
vor(1500); // Finger gleitet noch
|
||||
expect(result.current.phase).toBe("tippen");
|
||||
expect(result.current.klick).toBe(0);
|
||||
vor(1000); // Tipp
|
||||
expect(result.current.phase).toBe("tippen");
|
||||
expect(result.current.klick).toBe(1);
|
||||
vor(PINNWAND_RUNDE_MS - 2500); // Ende der ersten Runde
|
||||
expect(result.current.phase).toBe("warten");
|
||||
expect(result.current.klick).toBe(0);
|
||||
vor(2500);
|
||||
expect(result.current.klick).toBe(2);
|
||||
});
|
||||
|
||||
it("steht still und startet keinen Timer, solange laeuft = false ist", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = phasen(false, false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
vor(60_000);
|
||||
expect(result.current.phase).toBe("warten");
|
||||
expect(result.current.klick).toBe(0);
|
||||
expect(result.current.statisch).toBe(false);
|
||||
});
|
||||
|
||||
it("setzt beim Anhalten auf „warten“ zurück und beginnt beim nächsten Start von vorn", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result, rerender } = phasen(true, false);
|
||||
vor(2000);
|
||||
expect(result.current.phase).toBe("tippen");
|
||||
rerender({ l: false, s: false });
|
||||
expect(result.current.phase).toBe("warten");
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
rerender({ l: true, s: false });
|
||||
expect(result.current.phase).toBe("warten");
|
||||
vor(1500);
|
||||
expect(result.current.phase).toBe("tippen");
|
||||
});
|
||||
|
||||
it("zeigt statisch (keine Bewegung) sofort den Endzustand ohne Timer", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = phasen(true, true);
|
||||
expect(result.current.phase).toBe("haengt");
|
||||
expect(result.current.statisch).toBe(true);
|
||||
expect(result.current.klick).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
vor(30_000);
|
||||
expect(result.current.phase).toBe("haengt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Quelltext", () => {
|
||||
const quelle = (name: string) => readFileSync(`${process.cwd()}/src/components/home/demos/pinnwand/${name}`, "utf8");
|
||||
|
||||
it("ablauf.ts importiert kein CSS (die Komponenten werden später per React.lazy geladen)", () => {
|
||||
expect(quelle("ablauf.ts")).not.toMatch(/import\s+["'][^"']+\.css["']|from\s+["'][^"']+\.css["']/);
|
||||
});
|
||||
|
||||
it("lädt nichts von fremden Servern und enthält keine fremden Marken", () => {
|
||||
for (const datei of ["PinnwandSeite.tsx", "PinnwandHandy.tsx", "pinnwand.css"]) {
|
||||
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/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("animiert nur transform und opacity und nur ohne reduzierte Bewegung", () => {
|
||||
const css = quelle("pinnwand.css");
|
||||
// alle @keyframes enthalten ausschließlich transform, opacity und animation-timing-function
|
||||
for (const treffer of css.matchAll(/@keyframes[^{]+\{([\s\S]*?)\n\}/g)) {
|
||||
const eigenschaften = [...treffer[1].matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1]);
|
||||
for (const e of eigenschaften) expect(["transform", "opacity", "animation-timing-function"]).toContain(e);
|
||||
}
|
||||
// jede animation- und transition-Zeile steht hinter prefers-reduced-motion: no-preference
|
||||
const vorBewegung = css.split("@media (prefers-reduced-motion: no-preference)")[0];
|
||||
expect(vorBewegung).not.toMatch(/animation\s*:|transition\s*:/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, renderHook, act, cleanup, screen } from "@testing-library/react";
|
||||
import TerminSeite from "@/components/home/demos/termin/TerminSeite";
|
||||
import TerminHandy from "@/components/home/demos/termin/TerminHandy";
|
||||
import { useTerminAblauf, ZEIGER, type TerminPhase } from "@/components/home/demos/termin/ablauf";
|
||||
|
||||
// Demo „Terminplaner“ (Salon Schnittstelle), Spezifikation 2026-10-04-funktionen-demos 3.1
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const endzustand = () => renderHook(() => useTerminAblauf(false, true)).result.current;
|
||||
|
||||
/** Spult die Zeit in kleinen Schritten vor, damit React zwischen den Timern neu rendern kann. */
|
||||
const vor = (ms: number) => {
|
||||
for (let t = 0; t < ms; t += 50) {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const FOKUSSIERBAR = "a,button,input,select,textarea,[tabindex]";
|
||||
|
||||
describe("Terminplaner-Demo: statischer Endzustand", () => {
|
||||
it("zeigt am Desktop den Eintrag im Kalender und keinen Zeiger", () => {
|
||||
const z = endzustand();
|
||||
expect(z.phase).toBe("kalender");
|
||||
expect(z.zeigerSichtbar).toBe(false);
|
||||
const { container } = render(<TerminSeite zustand={z} variante="desktop" />);
|
||||
expect(screen.getByTestId("ts-kal-neu")).toHaveTextContent("Damen");
|
||||
expect(screen.getByTestId("ts-kal-neu")).toHaveTextContent("Laura W.");
|
||||
expect(screen.getByTestId("ts-kal-info")).toHaveTextContent("Damenhaarschnitt: Laura W.");
|
||||
expect(screen.getByText("Kalender-Abo · Mia")).toBeInTheDocument();
|
||||
expect(screen.getByText("z. B. in Google Kalender")).toBeInTheDocument();
|
||||
expect(screen.getByText("Termin bestätigt")).toBeInTheDocument();
|
||||
expect(container.firstElementChild).toHaveAttribute("data-phase", "kalender");
|
||||
expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
|
||||
expect(container.querySelectorAll(".ts-kal-eintrag").length).toBe(9);
|
||||
});
|
||||
|
||||
it("zeigt am Handy die Tagesliste des Kalender-Abos mit der gebuchten Zeile", () => {
|
||||
const { container } = render(<TerminSeite zustand={endzustand()} variante="handy" />);
|
||||
const karte = screen.getByTestId("ts-handy-karte");
|
||||
expect(karte).toHaveTextContent("Kalender-Abo · Mia");
|
||||
expect(karte).toHaveTextContent("14:30");
|
||||
expect(karte).toHaveTextContent("Damenhaarschnitt: Laura W.");
|
||||
expect(karte.querySelector(".ts-mini-zeile--neu")).toHaveAttribute("data-an", "ja");
|
||||
expect(container.firstElementChild).toHaveAttribute("data-variante", "handy");
|
||||
});
|
||||
|
||||
it("zeigt vor der Buchung die Zeile noch nicht, und die Seite steht ohne Zustand im Anfangszustand da", () => {
|
||||
const { container } = render(<TerminSeite variante="handy" />);
|
||||
expect(container.firstElementChild).toHaveAttribute("data-phase", "start");
|
||||
expect(container.querySelector(".ts-mini-zeile--neu")).toHaveAttribute("data-an", "nein");
|
||||
expect(container.querySelector(".ts-kal-neu")).toBeNull();
|
||||
});
|
||||
|
||||
it("legt alles Wichtige links von x 1140 und die Zeigerziele ebenfalls (das iPhone der Bühne deckt ab x 1158 / y 419)", () => {
|
||||
for (const ziel of ["leistung", "person", "zeit", "buchen"] as const) {
|
||||
expect(ZEIGER[ziel].desktop.x).toBeLessThan(1140);
|
||||
}
|
||||
});
|
||||
|
||||
it("zeigt die Mitteilung im Handy der Bühne (Absender WEBklar, Betreff wie K20)", () => {
|
||||
const { container } = render(<TerminHandy zustand={endzustand()} />);
|
||||
const m = screen.getByTestId("ts-mitteilung");
|
||||
expect(m).toHaveTextContent("WEBklar");
|
||||
expect(m).toHaveTextContent("Neue Buchung: Damenhaarschnitt am Do., 8. Okt. um 14:30");
|
||||
expect(m).toHaveTextContent("jetzt");
|
||||
expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
it("enthält in der Website nichts Fokussierbares", () => {
|
||||
for (const variante of ["desktop", "handy"] as const) {
|
||||
const { container, unmount } = render(<TerminSeite zustand={endzustand()} variante={variante} />);
|
||||
expect(container.querySelectorAll(FOKUSSIERBAR)).toHaveLength(0);
|
||||
unmount();
|
||||
}
|
||||
const { container } = render(<TerminHandy zustand={endzustand()} />);
|
||||
expect(container.querySelectorAll(FOKUSSIERBAR)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTerminAblauf", () => {
|
||||
it("läuft durch die Phasen und beginnt von vorn", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() => useTerminAblauf(true, false));
|
||||
const gesehen: TerminPhase[] = [result.current.phase];
|
||||
expect(result.current.phase).toBe("start");
|
||||
expect(result.current.gewaehlt).toEqual({ leistung: false, person: false, zeit: false });
|
||||
let klickMax = 0;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
vor(250);
|
||||
const p = result.current.phase;
|
||||
if (gesehen[gesehen.length - 1] !== p) gesehen.push(p);
|
||||
klickMax = Math.max(klickMax, result.current.klick);
|
||||
}
|
||||
// 10 s sind vergangen: bis „kalender“ gelaufen
|
||||
expect(gesehen.slice(0, 8)).toEqual(["start", "leistung", "person", "zeit", "buchen", "mail", "bestaetigt", "kalender"]);
|
||||
expect(klickMax).toBe(4);
|
||||
// nach Ende (ca. 11,7 s) wieder von vorn
|
||||
vor(3000);
|
||||
expect(["start", "leistung"]).toContain(result.current.phase);
|
||||
});
|
||||
|
||||
it("markiert die Auswahl nacheinander und zeigt den Zeiger nur beim Auswählen", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() => useTerminAblauf(true, false));
|
||||
expect(result.current.zeigerSichtbar).toBe(true);
|
||||
vor(900 + 850 + 450);
|
||||
expect(result.current.gewaehlt.leistung).toBe(true);
|
||||
expect(result.current.gewaehlt.person).toBe(false);
|
||||
vor(3400);
|
||||
expect(result.current.gewaehlt).toEqual({ leistung: true, person: true, zeit: true });
|
||||
vor(100);
|
||||
expect(result.current.phase).toBe("mail");
|
||||
expect(result.current.zeigerSichtbar).toBe(false);
|
||||
});
|
||||
|
||||
it("steht bei laeuft=false still und startet keine Timer", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() => useTerminAblauf(false, false));
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
vor(20000);
|
||||
expect(result.current.phase).toBe("start");
|
||||
expect(result.current.laeuft).toBe(false);
|
||||
});
|
||||
|
||||
it("hält an, sobald laeuft auf false fällt, und springt auf den Anfang", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result, rerender } = renderHook(({ l }) => useTerminAblauf(l, false), { initialProps: { l: true } });
|
||||
vor(4000);
|
||||
expect(result.current.phase).not.toBe("start");
|
||||
rerender({ l: false });
|
||||
expect(result.current.phase).toBe("start");
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("statisch: Endzustand ohne Zeiger und ohne Timer", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() => useTerminAblauf(true, true));
|
||||
expect(result.current.phase).toBe("kalender");
|
||||
expect(result.current.zeigerSichtbar).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,24 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, within, act, cleanup } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, within, act, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
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)
|
||||
// Spezifikation 7.2 (Kundenportal) und 9.5 (Bilder); Funktions-Demos: 2026-10-04-funktionen-demos (Stationen 5 bis 7)
|
||||
|
||||
const WURZEL = `${process.cwd()}/`;
|
||||
const BILDORDNER = join(WURZEL, "public/relaunch/portal");
|
||||
|
||||
const TITEL = ["Live-Vorschau", "Fortschritt", "Chat", "Rechnungen", "Funktionen", "Als App"];
|
||||
const TITEL = ["Live-Vorschau", "Fortschritt", "Chat", "Rechnungen", "Terminplaner", "Website-Analyse", "News-Pinnwand", "Als App"];
|
||||
const UNTERSCHRIFTEN = [
|
||||
"Ihre Website, noch während wir daran bauen.",
|
||||
"Was wir zuletzt erledigt haben – in einfachen Sätzen.",
|
||||
"Frage, Foto oder Sprachnachricht direkt ans Team.",
|
||||
"Ansehen, bezahlen, herunterladen.",
|
||||
"Bausteine zum Dazubuchen, zum Beispiel eine News-Pinnwand für Ihre Website.",
|
||||
"Gäste buchen auf Ihrer Website – die Termine sehen Sie im Portal und auf Wunsch im Google Kalender.",
|
||||
"Sehen Sie, wohin Ihre Besucher klicken. Zeigen Sie mit der Maus auf die Seite.",
|
||||
"Neuigkeiten mit Text und Foto, vom Handy direkt auf Ihre Website.",
|
||||
"Aufs Handy installieren, fertig.",
|
||||
];
|
||||
const SCHRITTE = [
|
||||
@@ -26,11 +28,11 @@ const SCHRITTE = [
|
||||
"Online – Hosting und Pflege auf Wunsch im Abo",
|
||||
];
|
||||
|
||||
/** matchMedia: nur die Abfrage "ab 900 px" ist wahr, wenn desktop = true. */
|
||||
const mitBreite = (desktop: boolean) => {
|
||||
/** matchMedia: die Abfrage "ab 900 px" ist wahr, wenn desktop = true; "hover: hover" (feiner Zeiger, Maus), wenn fein = true. */
|
||||
const mitBreite = (desktop: boolean, fein = desktop) => {
|
||||
const vorher = window.matchMedia;
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: desktop && query.includes("min-width: 900px"),
|
||||
matches: (desktop && query.includes("min-width: 900px")) || (fein && query.includes("hover: hover")),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
@@ -53,9 +55,20 @@ const zeigeDesktop = () => {
|
||||
zuruecksetzen = mitBreite(true);
|
||||
return render(<Kundenportal />);
|
||||
};
|
||||
/** Bühne ab 900 px, aber ohne Maus (Tablet quer). */
|
||||
const zeigeTablet = () => {
|
||||
zuruecksetzen = mitBreite(true, false);
|
||||
return render(<Kundenportal />);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// jsdom hat keine 2D-Leinwand; die Heatmap-Demo überspringt dann die Zeichnung
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
zuruecksetzen?.();
|
||||
zuruecksetzen = undefined;
|
||||
vi.unstubAllGlobals();
|
||||
@@ -74,31 +87,49 @@ describe("Kundenportal: Text und Reihenfolge", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Handy: die sechs Funktionen stehen in der richtigen Reihenfolge mit ihren Unterschriften", () => {
|
||||
it("Handy: die acht 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) expect(screen.getByText(text)).toBeInTheDocument();
|
||||
// in der Wisch-Reihe gibt es keine Maus: die Analyse sagt "Tippen" statt "mit der Maus zeigen"
|
||||
const handyTexte = UNTERSCHRIFTEN.map((t) => (t.includes("mit der Maus") ? "Sehen Sie, wohin Ihre Besucher klicken. Tippen Sie auf eine Stelle der Seite." : t));
|
||||
for (const text of handyTexte) expect(screen.getByText(text)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Desktop: die Liste der Bühne hat dieselben sechs Funktionen in derselben Reihenfolge", () => {
|
||||
it("Desktop: die Liste der Bühne hat dieselben acht Funktionen in derselben Reihenfolge, die drei Demos als Gruppe", () => {
|
||||
zeigeDesktop();
|
||||
const liste = screen.getByRole("list", { name: "Funktionen des Kundenportals" });
|
||||
const eintraege = within(liste).getAllByRole("button");
|
||||
expect(eintraege).toHaveLength(6);
|
||||
expect(eintraege).toHaveLength(8);
|
||||
TITEL.forEach((titel, i) => {
|
||||
expect(eintraege[i]).toHaveTextContent(new RegExp(`^${titel}`));
|
||||
expect(eintraege[i]).toHaveTextContent(UNTERSCHRIFTEN[i]);
|
||||
});
|
||||
// Gruppenüberschrift steht über dem ersten der drei Funktions-Einträge, die Gruppe umfasst genau diese drei
|
||||
const gruppe = screen.getByText("Funktionen zum Dazubuchen");
|
||||
expect(gruppe.closest("li")).toContainElement(eintraege[4]);
|
||||
expect(Array.from(liste.querySelectorAll("li.kp-gruppe"))).toHaveLength(3);
|
||||
expect(eintraege[3].closest("li")).not.toHaveClass("kp-gruppe");
|
||||
expect(eintraege[7].closest("li")).not.toHaveClass("kp-gruppe");
|
||||
});
|
||||
|
||||
it("Tablet quer (Bühne ohne Maus): die Unterschrift der Website-Analyse sagt „Tippen Sie“ statt „Zeigen Sie mit der Maus“", () => {
|
||||
zeigeTablet();
|
||||
const liste = screen.getByRole("list", { name: "Funktionen des Kundenportals" });
|
||||
const analyse = within(liste).getAllByRole("button")[5];
|
||||
expect(analyse).toHaveTextContent("Tippen Sie auf eine Stelle der Seite.");
|
||||
expect(analyse).not.toHaveTextContent("Maus");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Handy", zeigeHandy],
|
||||
["Desktop", zeigeDesktop],
|
||||
])("%s: bewirbt weder Terminplaner noch Analyse, Stundenkonto oder Verträge", (_name, zeige) => {
|
||||
])("%s: bewirbt weder Stundenkonto noch Verträge (Terminplaner, Analyse und Pinnwand 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(" ")}`;
|
||||
expect(gesamt).not.toMatch(/Terminplaner|Analyse|Stundenkonto|Verträge|Vertrag/i);
|
||||
expect(gesamt).not.toMatch(/Stundenkonto|Verträge|Vertrag/i);
|
||||
// keine Erfolgsversprechen in den Demo-Texten (AGB § 2)
|
||||
expect(gesamt).not.toMatch(/mehr Umsatz|mehr Kunden|garantiert/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,16 +152,16 @@ describe("Kundenportal: Bilder (Spezifikation 9.5)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("Handy: sechs Bilder, nur Handy-Aufnahmen; das lange Bild behält sein hohes Seitenverhältnis", () => {
|
||||
it("Handy: fünf Bilder, nur Handy-Aufnahmen (die drei 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(6);
|
||||
for (const bild of bilder.slice(0, 5)) {
|
||||
expect(bilder).toHaveLength(5);
|
||||
for (const bild of bilder.slice(0, 4)) {
|
||||
expect(bild.getAttribute("width")).toBe("780");
|
||||
expect(bild.getAttribute("height")).toBe("1696");
|
||||
}
|
||||
expect(bilder[5].getAttribute("src")).toContain("phone-2-projekte-lang");
|
||||
expect(bilder[5].getAttribute("height")).toBe("4978");
|
||||
expect(bilder[4].getAttribute("src")).toContain("phone-2-projekte-lang");
|
||||
expect(bilder[4].getAttribute("height")).toBe("4978");
|
||||
});
|
||||
|
||||
it("Desktop: zu Beginn hängen nur die ersten beiden Schritte ihre Bilder ein (spart Daten)", () => {
|
||||
@@ -165,8 +196,15 @@ describe("Kundenportal: Bilder (Spezifikation 9.5)", () => {
|
||||
|
||||
it("in public/ liegen nur die Bilder, die der Abschnitt zeigt (keine Rohexporte, keine unbeworbenen Ansichten)", () => {
|
||||
const skript = readFileSync(join(WURZEL, "scripts/relaunch-bilder.py"), "utf8");
|
||||
expect(skript).toContain("desktop-7-news-pinnwand");
|
||||
for (const nicht of ["desktop-5-stundenkonto", "desktop-6-funktionen", "phone-6-vertraege", "phone-0-login"]) {
|
||||
// die alten Ansichten der News-Pinnwand ersetzt die Live-Demo (Station 7): ihre Bilder werden nicht mehr ausgeliefert
|
||||
for (const nicht of [
|
||||
"desktop-5-stundenkonto",
|
||||
"desktop-6-funktionen",
|
||||
"phone-6-vertraege",
|
||||
"phone-0-login",
|
||||
"desktop-7-news-pinnwand",
|
||||
"phone-8-news-vorschau",
|
||||
]) {
|
||||
expect(skript, nicht).not.toContain(nicht);
|
||||
expect(readdirSync(BILDORDNER).filter((d) => d.startsWith(nicht)), nicht).toEqual([]);
|
||||
}
|
||||
@@ -200,16 +238,17 @@ describe("Kundenportal: Schrittzeile", () => {
|
||||
});
|
||||
|
||||
describe("Kundenportal: Wisch-Reihen und Bedienung", () => {
|
||||
it("Handy: beide Wisch-Reihen sind per Tastatur erreichbar und beschriftet, höchstens 7 Karten", () => {
|
||||
it("Handy: beide Wisch-Reihen sind per Tastatur erreichbar und beschriftet, höchstens 8 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(7);
|
||||
expect(reihe.children.length).toBeLessThanOrEqual(8);
|
||||
}
|
||||
expect(reihen[0].children).toHaveLength(6);
|
||||
expect(reihen[0].children).toHaveLength(8);
|
||||
expect(reihen[0].getAttribute("aria-label")).toMatch(/^Acht Funktionen/);
|
||||
});
|
||||
|
||||
it("Desktop: keine Wisch-Reihe, dafür die Bühne", () => {
|
||||
@@ -252,7 +291,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(6);
|
||||
expect(spur!.ziele).toHaveLength(8);
|
||||
act(() => {
|
||||
spur!.rueckruf([{ isIntersecting: true, target: spur!.ziele[index] } as unknown as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
});
|
||||
@@ -264,7 +303,7 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
.find((b) => b.getAttribute("aria-current") === "step")
|
||||
?.textContent;
|
||||
|
||||
it("die aktive Funktion ist hervorgehoben, die Bilder folgen, bei Schritt 6 blendet das Fenster aus", () => {
|
||||
it("die aktive Funktion ist hervorgehoben, die Bilder folgen, bei Schritt 8 blendet das Fenster aus", () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
expect(aktiverEintrag()).toMatch(/^Live-Vorschau/);
|
||||
@@ -280,7 +319,7 @@ 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(5);
|
||||
meldeMarke(7);
|
||||
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");
|
||||
@@ -288,7 +327,157 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
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
|
||||
expect(container.querySelectorAll("img").length).toBe(5 + 6);
|
||||
expect(container.querySelectorAll("img").length).toBe(4 + 8);
|
||||
});
|
||||
|
||||
it("Demo-Stationen: die drei 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();
|
||||
|
||||
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);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".kp-fenster .hm")).not.toBeNull();
|
||||
expect(container.querySelector(".kp-fenster .pw-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"))) {
|
||||
expect(wurzel).toHaveAttribute("aria-hidden", "true");
|
||||
expect(wurzel).toHaveAttribute("data-phase");
|
||||
expect(wurzel.querySelectorAll("a, button, input, select, textarea, [tabindex]")).toHaveLength(0);
|
||||
}
|
||||
// auf dem iPhone liegt über dem Portal-Bild die Live-Ebene der Station (hier: Pinnwand-Finger, Termin-Mitteilung)
|
||||
await waitFor(() => expect(container.querySelector(".kp-handy .tsh")).not.toBeNull());
|
||||
expect(container.querySelector(".kp-handy .kp-lage.is-aktiv img")?.getAttribute("src")).toContain("phone-11-news-musterbach");
|
||||
expect(container.querySelector(".kp-handy .kp-lage.is-aktiv .dz-zeiger")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("die Adresse in der Safari-Leiste wechselt mit der aktiven Station", async () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
const adresse = () => container.querySelector(".dm-macbook__url")?.textContent;
|
||||
expect(adresse()).toBe("project.webklar.com");
|
||||
meldeMarke(4);
|
||||
expect(adresse()).toBe("salon-schnittstelle.example");
|
||||
meldeMarke(5);
|
||||
expect(adresse()).toBe("boulderhalle-ueberhang.example");
|
||||
meldeMarke(6);
|
||||
expect(adresse()).toBe("musterbach.example");
|
||||
meldeMarke(2);
|
||||
expect(adresse()).toBe("project.webklar.com");
|
||||
await waitFor(() => expect(container.querySelector(".kp-fenster .ts")).not.toBeNull());
|
||||
});
|
||||
|
||||
it("die Heatmap-Diagnose ist nur bei aktiver Station Website-Analyse sichtbar und bedienbar, sonst inert", async () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
const diagnose = () => container.querySelector(".kp-diagnose") as HTMLElement;
|
||||
expect(diagnose()).toHaveAttribute("data-an", "nein");
|
||||
expect(diagnose()).toHaveAttribute("aria-hidden", "true");
|
||||
expect(diagnose()).toHaveAttribute("inert");
|
||||
|
||||
meldeMarke(5);
|
||||
await waitFor(() => expect(diagnose().querySelector(".hmd")).not.toBeNull());
|
||||
expect(diagnose()).toHaveAttribute("data-an", "ja");
|
||||
expect(diagnose()).not.toHaveAttribute("aria-hidden");
|
||||
expect(diagnose()).not.toHaveAttribute("inert");
|
||||
expect(diagnose()).toHaveTextContent("Beispieldaten");
|
||||
expect(within(diagnose()).getAllByRole("button")).toHaveLength(4);
|
||||
// der Hinweis aufs erfundene Beispiel steht daneben; nur der der aktiven Station ist für Screenreader da
|
||||
const hinweise = Array.from(container.querySelectorAll(".kp-beispiel"));
|
||||
expect(hinweise.map((h) => h.textContent)).toEqual([
|
||||
"Erfundenes Beispiel · salon-schnittstelle.example",
|
||||
"Erfundenes Beispiel · boulderhalle-ueberhang.example",
|
||||
"Erfundenes Beispiel · musterbach.example",
|
||||
]);
|
||||
expect(hinweise.map((h) => h.getAttribute("data-an"))).toEqual(["nein", "ja", "nein"]);
|
||||
|
||||
meldeMarke(6);
|
||||
expect(diagnose()).toHaveAttribute("data-an", "nein");
|
||||
expect(diagnose()).toHaveAttribute("inert");
|
||||
});
|
||||
|
||||
it("Terminplaner und Pinnwand tragen unter dem MacBook eine Hinweiskarte (Kalender-Abo mit Verzögerung, neuer Zettel); nur die der aktiven Station ist sichtbar", () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
const hinweise = () => Array.from(container.querySelectorAll(".kp-hinweis")) as HTMLElement[];
|
||||
expect(hinweise().map((h) => h.textContent)).toEqual([
|
||||
"Kalender-Abo: Den Link einmal in Google Kalender, Apple Kalender oder Outlook einfügen. Neue Termine erscheinen dort mit etwas Verzögerung.",
|
||||
"Neuer Zettel: Er erscheint nach dem Aufhängen auf der Website.",
|
||||
]);
|
||||
meldeMarke(4);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["ja", "nein"]);
|
||||
expect(hinweise().map((h) => h.hasAttribute("aria-hidden"))).toEqual([false, true]);
|
||||
meldeMarke(5);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein"]);
|
||||
meldeMarke(6);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "ja"]);
|
||||
});
|
||||
|
||||
it("die aktive Demo-Ebene trägt eine kurze Beschreibung für Screenreader, die Website darin bleibt aria-hidden", async () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
meldeMarke(4);
|
||||
await waitFor(() => expect(container.querySelector(".kp-fenster .ts")).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(/Friseursalon/);
|
||||
expect(ebene.querySelector(".ts")).toHaveAttribute("aria-hidden", "true");
|
||||
// inaktive Ebenen: ausgeblendet und ohne Beschreibung
|
||||
const andere = Array.from(container.querySelectorAll(".kp-fenster .kp-lage:not(.is-aktiv)"));
|
||||
for (const e of andere) {
|
||||
expect(e).toHaveAttribute("aria-hidden", "true");
|
||||
expect(e).not.toHaveAttribute("role");
|
||||
}
|
||||
});
|
||||
|
||||
it("der Platzhalter in Seitenfarbe steht, bis die Website geladen ist (keine schwarze Fläche im MacBook)", () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
meldeMarke(4);
|
||||
// direkt nach dem Umschalten ist der Chunk der Website noch nicht da: Platzhalter oder schon die Website
|
||||
const lage = container.querySelector(".kp-fenster .kp-lage.is-aktiv") as HTMLElement;
|
||||
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 () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeHandy();
|
||||
const karten = Array.from(container.querySelectorAll(".kp-karte--demo"));
|
||||
expect(karten).toHaveLength(3);
|
||||
// noch nichts gesehen: Rahmen leer, nichts geladen
|
||||
expect(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite)")).toHaveLength(0);
|
||||
// jede Karte meldet sich über ihre Beobachter als sichtbar
|
||||
act(() => {
|
||||
for (const b of beobachter) {
|
||||
// maßgeblich ist das Handy der Karte (nicht die ganze Karte, die höher als der Bildschirm sein kann)
|
||||
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(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']")))
|
||||
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;
|
||||
await waitFor(() => expect(heat.querySelector(".hmd--kompakt")).not.toBeNull());
|
||||
expect(heat.querySelectorAll(".hmd__zeile")).toHaveLength(3);
|
||||
const zone = heat.querySelector("[data-zone='routenbild']") as HTMLElement;
|
||||
expect(zone).not.toBeNull();
|
||||
fireEvent.click(zone);
|
||||
expect(heat.querySelector(".hmd")).toHaveAttribute("data-auswahl", "routenbild");
|
||||
// Hinweis aufs erfundene Beispiel an jeder Demo-Karte
|
||||
for (const karte of karten) expect(karte.querySelector(".kp-karte__beispiel")?.textContent).toMatch(/^Erfundenes Beispiel · .+\.example$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -388,6 +577,12 @@ describe("Kundenportal: CSS-Regeln (Spezifikation 8.4)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("inaktive Ebenen der Bühne reagieren nicht auf die Maus, nur die aktive (Heatmap-Hover)", () => {
|
||||
const text = css[0].text.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
expect(text).toMatch(/\.kp-lage\s*\{[^}]*pointer-events:\s*none/);
|
||||
expect(text).toMatch(/\.kp-lage\.is-aktiv\s*\{[^}]*pointer-events:\s*auto/);
|
||||
});
|
||||
|
||||
it("es wird nur transform und opacity bewegt (keine Layout-Eigenschaften in Übergängen)", () => {
|
||||
for (const { pfad, text } of css) {
|
||||
for (const m of text.matchAll(/transition(?:-property)?:\s*([^;]+);/g)) {
|
||||
|
||||
Reference in new issue
Block a user