Files
Webklar/src/components/home/Kundenportal.tsx
T
rootandClaude Opus 5.5 9914002e74 Bühne: „Funktionen“ als aufklappender Überpunkt, Heatmap mit Klicks auch außerhalb der Tageskarte
- Liste: Überpunkt „Funktionen“ (Bausteine für Ihre Website). Er klappt auf, sobald
  Terminplaner, Website-Analyse oder News-Pinnwand erreicht ist, und danach wieder zu.
  Die drei Unterpunkte zeigen nur ihren Titel (keine Stichpunkte); die Liste steht oben
  in der Bühne, damit beim Aufklappen nichts darüber springt.
- Heatmap der Boulderhalle: jede Zone bekommt eine Mindestzahl Klickpunkte (Telefon,
  Menü, Routenbild, Karten sind sichtbar), die Tageskarte bleibt klar am heißesten.
  Telefonnummer 96 statt 61 Klicks (getauscht mit Öffnungszeiten, Summe bleibt 1.284).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:15:47 +00:00

779 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
/**
* Abschnitt Kundenportal (Spezifikation 7.2).
* 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 =
"Sie sehen, was wir zuletzt erledigt haben, öffnen die Live-Vorschau Ihrer Website und schreiben uns im Chat – mit Fotos, Dateien oder Sprachnachricht. Ihre Rechnungen liegen ebenfalls dort, und aufs Handy installieren Sie das Portal wie eine App.";
// ── Bilder (erzeugt von scripts/relaunch-bilder.py nach public/relaunch/portal/) ──────────────────────────
const BILDER = "/relaunch/portal";
const DESKTOP_BREITEN = [800, 1200, 1600];
const HANDY_BREITEN = [390, 780];
const srcSet = (name: string, breiten: number[]) =>
breiten.map((b) => `${BILDER}/${name}-${b}.webp ${b}w`).join(", ");
/** Desktop-Bild: Rohformat 3024 x 1786 (MacBook-Safari-Fläche 1512 x 893 @2x), ausgeliefert in 800 / 1200 / 1600 px. */
const desktopBild = (name: string, alt: string): RahmenBildDaten => ({
src: `${BILDER}/${name}-1200.webp`,
srcSet: srcSet(name, DESKTOP_BREITEN),
sizes: "(min-width: 1200px) 692px, 50vw",
width: 1600,
height: 945,
alt,
});
/** Handy-Bild: Rohformat 804 x 1748 = iPhone 16 Pro @2x (das lange Bild 804 x 5131), ausgeliefert in 390 / 780 px;
* width/height geben das Seitenverhältnis bei 780 px an. */
const handyBild = (name: string, alt: string, sizes: string, hoehe = 1696): RahmenBildDaten => ({
src: `${BILDER}/${name}-780.webp`,
srcSet: srcSet(name, HANDY_BREITEN),
sizes,
width: 780,
height: hoehe,
alt,
});
type DemoArt = "termin" | "heatmap" | "pinnwand";
type Funktion = {
id: string;
titel: string;
/** Ein Satz unter dem Titel (Portal-Stationen); die Funktionen haben stattdessen Stichpunkte. */
unterschrift?: string;
/** Stichpunkte unter dem Titel (die drei Funktionen, Wunsch des Inhabers 2026-10-04). */
stichpunkte?: string[];
/** Bedienhinweis zur Live-Demo, je nachdem, ob es eine Maus gibt (nur Website-Analyse). */
bedienhinweis?: { maus: string; tippen: 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[] = [
{
id: "live-vorschau",
titel: "Live-Vorschau",
unterschrift: "Ihre Website, noch während wir daran bauen.",
desktop: { name: "desktop-1-projekte", alt: "Kundenportal: Projektübersicht mit Live-Vorschau der Website" },
handy: { name: "phone-1-projekte", alt: "Kundenportal auf dem Handy: Projektübersicht mit Live-Vorschau der Website" },
},
{
id: "fortschritt",
titel: "Fortschritt",
unterschrift: "Was wir zuletzt erledigt haben – in einfachen Sätzen.",
desktop: {
name: "desktop-2-fortschritt",
alt: "Kundenportal: zuletzt erledigte Arbeiten am Auftrag, in einfachen Sätzen beschrieben",
},
handy: {
name: "phone-3-projektdetails",
alt: "Kundenportal auf dem Handy: Projektdetails mit den letzten Änderungen",
},
},
{
id: "chat",
titel: "Chat",
unterschrift: "Frage, Foto oder Sprachnachricht direkt ans Team.",
desktop: { name: "desktop-3b-chat-foto", alt: "Kundenportal: Chat mit dem Team, im Verlauf ein Foto" },
handy: { name: "phone-4b-chat-foto", alt: "Kundenportal auf dem Handy: Chat mit Foto" },
},
{
id: "rechnungen",
titel: "Rechnungen",
unterschrift: "Ansehen, bezahlen, herunterladen.",
desktop: { name: "desktop-4-rechnungen", alt: "Kundenportal: Rechnungen mit Betrag und Status" },
handy: { name: "phone-5-rechnungen", alt: "Kundenportal auf dem Handy: Rechnungen zum Ansehen, Bezahlen und Herunterladen" },
},
{
id: "terminplaner",
titel: "Terminplaner",
stichpunkte: ["Gäste buchen direkt auf Ihrer Website", "Termine und Anfragen im Portal", "Kalender-Abo, z. B. 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-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",
stichpunkte: ["Heatmap: wohin Besucher klicken", "Aufrufe, Herkunft und Geräte", "Im Wartungs-Abo enthalten"],
bedienhinweis: { maus: "Zeigen Sie mit der Maus auf die Seite.", tippen: "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",
stichpunkte: ["Text und Foto, vom Handy aus", "Hängt direkt auf Ihrer Website", "Laufzeit je Zettel wählbar"],
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.",
},
},
{
id: "als-app",
titel: "Als App",
unterschrift: "Aufs Handy installieren, fertig.",
handy: {
name: "phone-2-projekte-lang",
alt: "Kundenportal als App auf dem Handy: lange Projektseite mit Vorschau, Status und letzten Änderungen",
},
lang: true,
},
];
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;
/** Überpunkt der drei Funktionen in der Liste der Bühne (Wunsch des Inhabers 2026-10-04): klappt auf, sobald eine
* der drei erreicht ist; die Unterpunkte zeigen nur ihren Titel. */
const GRUPPE = { titel: "Funktionen", text: "Bausteine für Ihre Website." };
const DEMO_INDIZES = FUNKTIONEN.flatMap((f, i) => (f.demo ? [i] : []));
// ── 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}`;
/**
* Unterschrift einer Station: ein Satz oder Stichpunkte. In der Liste steckt sie in einem <button> (nur Phrasing-Inhalt
* erlaubt), darum sind die Stichpunkte dort Blöcke aus <span>; das versteckte Komma trennt sie für Screenreader.
*/
const Unterschrift = ({ f }: { f: Funktion }) =>
f.stichpunkte ? (
<>
{f.stichpunkte.map((p, i) => (
<span key={p} className="kp-stichpunkt">
{p}
{i < f.stichpunkte!.length - 1 ? <span className="sr-only">, </span> : null}
</span>
))}
</>
) : (
<>{f.unterschrift}</>
);
/** Unterschrift in den Karten der Wisch-Reihe (außerhalb eines Knopfs): Satz oder echte Liste. */
const KartenUnterschrift = ({ f }: { f: Funktion }) =>
f.stichpunkte ? (
<ul className="wk-muted kp-karte__punkte">
{f.stichpunkte.map((p) => (
<li key={p}>{p}</li>
))}
</ul>
) : (
<p className="wk-muted kp-karte__text">{f.unterschrift}</p>
);
const SCHRITTE = [
{ nr: 1, text: "Kennenlernen" },
{ nr: 2, text: "Angebot mit Preis" },
{ nr: 3, text: "Bauen mit Vorschau und Ihrer Freigabe" },
{ nr: 4, text: "Online – Hosting und Pflege auf Wunsch im Abo" },
];
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
/** Medienabfrage als Wert. Ohne matchMedia (SSR) gilt `standard`. */
const useMedia = (abfrage: string, standard = false) =>
useSyncExternalStore(
(melden) => {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mq = window.matchMedia(abfrage);
mq.addEventListener?.("change", melden);
return () => mq.removeEventListener?.("change", melden);
},
() => (typeof window !== "undefined" && window.matchMedia ? window.matchMedia(abfrage).matches : standard),
() => standard,
);
const begrenze = (wert: number, min: number, max: number) => Math.min(max, Math.max(min, wert));
// ── Desktop: festgepinnte Bühne ─────────────────────────────────────────────
/**
* Die Hülle ist hoch (Scrollstrecke), darin klebt die Bühne (position: sticky).
* Hinter der Bühne liegt eine unsichtbare Spur aus Markern, eine Marke je Funktion. Der IntersectionObserver
* meldet, welche Marke gerade die Bildschirmmitte kreuzt: das ist die aktive Funktion, das Bild blendet über.
* Beim letzten Schritt wird der Scrollfortschritt als CSS-Variable --kp-q (0 bis 1) an die Bühne geschrieben,
* und das CSS schiebt den langen Screenshot per translateY nach oben (nur transform).
*/
const Buehne = () => {
const [aktiv, setAktiv] = useState(0);
// Bilder werden erst eingehängt, wenn ihr Schritt (oder der davor) erreicht ist: spart Daten beim ersten Erreichen
const [hoechster, setHoechster] = useState(0);
const huelle = useRef<HTMLDivElement>(null);
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(
(eintraege) => {
for (const e of eintraege) {
if (!e.isIntersecting) continue;
const i = Number((e.target as HTMLElement).dataset.index);
setAktiv(i);
setHoechster((h) => Math.max(h, i));
}
},
// Messlinie in der Bildschirmmitte: die Marke, die sie kreuzt, ist die aktive
{ rootMargin: "-50% 0px -50% 0px", threshold: 0 },
);
marker.current.forEach((m) => m && beobachter.observe(m));
return () => beobachter.disconnect();
}, []);
// Scrollfortschritt des letzten Schritts (0 bis 1) für den langen Screenshot
useEffect(() => {
const el = buehne.current;
const huellenEl = huelle.current;
const letzterMarker = marker.current[LETZTER];
if (!el || !huellenEl || !letzterMarker || !window.matchMedia) return;
const ruhig = window.matchMedia("(prefers-reduced-motion: no-preference)");
let raf = 0;
const berechne = () => {
raf = 0;
if (!ruhig.matches) {
el.style.setProperty("--kp-q", "0");
return;
}
const vh = window.innerHeight;
const huelleBox = huellenEl.getBoundingClientRect();
if (huelleBox.bottom < -vh || huelleBox.top > 2 * vh) return;
const oben = parseFloat(getComputedStyle(el).top) || 0;
const gescrollt = oben - huelleBox.top; // Strecke seit dem Anheften
const strecke = huelleBox.height - el.offsetHeight; // gesamte Pin-Strecke
const seitSchritt6 = vh / 2 - letzterMarker.getBoundingClientRect().top;
const laenge = strecke - (gescrollt - seitSchritt6); // Länge des letzten Schritts
const vorlauf = 0.12 * vh; // erst bewegt sich der Rahmen, dann der Inhalt
const halten = 0.2 * vh; // am Ende bleibt der lange Screenshot kurz stehen
const q = begrenze((seitSchritt6 - vorlauf) / Math.max(1, laenge - vorlauf - halten), 0, 1);
el.style.setProperty("--kp-q", q.toFixed(4));
};
const planen = () => {
if (!raf) raf = requestAnimationFrame(berechne);
};
window.addEventListener("scroll", planen, { passive: true });
window.addEventListener("resize", planen);
ruhig.addEventListener?.("change", planen);
berechne();
return () => {
window.removeEventListener("scroll", planen);
window.removeEventListener("resize", planen);
ruhig.removeEventListener?.("change", planen);
if (raf) cancelAnimationFrame(raf);
};
}, []);
/** Klick in der Liste: zur Marke dieser Funktion scrollen (weich oder sofort entscheidet das CSS der Seite). */
const springe = (i: number) => {
const huellenEl = huelle.current;
const buehnenEl = buehne.current;
const m = marker.current[i];
if (!huellenEl || !buehnenEl || !m) return;
const oben = parseFloat(getComputedStyle(buehnenEl).top) || 0;
const huelleOben = huellenEl.getBoundingClientRect().top;
// Marke knapp über die Messlinie legen, mindestens aber so weit scrollen, dass die Bühne angeheftet ist
const zurMarke = huelleOben + m.offsetTop - (window.innerHeight / 2 - 1);
const zurBuehne = huelleOben - oben;
window.scrollTo({ top: window.scrollY + Math.max(zurMarke, zurBuehne) });
};
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}
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${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) => {
if (!f.demo) {
return (
<li key={f.id}>
<button
type="button"
className="kp-eintrag"
aria-current={i === aktiv ? "step" : undefined}
onClick={() => springe(i)}
>
<span className="kp-eintrag__punkt" aria-hidden="true" />
<span className="kp-eintrag__titel">{f.titel}</span>
<span className="kp-eintrag__text">
<Unterschrift f={f} />
</span>
</button>
</li>
);
}
// die drei Funktionen: ein Überpunkt, der aufklappt, sobald eine von ihnen erreicht ist
if (i !== DEMO_INDIZES[0]) return null;
const offen = aktivDemo !== undefined;
return (
<li key="funktionen" className="kp-gruppe" data-offen={offen ? "ja" : "nein"}>
<button
type="button"
className="kp-eintrag kp-eintrag--gruppe"
aria-expanded={offen}
aria-controls="kp-unterliste"
onClick={() => springe(DEMO_INDIZES[0])}
>
<span className="kp-eintrag__titel">{GRUPPE.titel}</span>
<span className="kp-eintrag__text">{GRUPPE.text}</span>
</button>
<ol id="kp-unterliste" className="kp-unterliste" aria-label={GRUPPE.titel} hidden={!offen}>
{DEMO_INDIZES.map((d) => (
<li key={FUNKTIONEN[d].id}>
<button
type="button"
className="kp-untereintrag"
aria-current={d === aktiv ? "step" : undefined}
onClick={() => springe(d)}
>
<span className="kp-eintrag__punkt" aria-hidden="true" />
<span className="kp-untereintrag__titel">{FUNKTIONEN[d].titel}</span>
</button>
</li>
))}
</ol>
</li>
);
})}
</ol>
<div className="kp-geraete">
<DesktopFenster className="kp-fenster" adresse={FUNKTIONEN[aktiv].adresse}>
<div className="kp-ebenen" aria-hidden={app || undefined}>
{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}
>
{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>
);
})}
</div>
</DesktopFenster>
<HandyRahmen className="kp-handy">
{FUNKTIONEN.map((f, i) =>
eingehaengt(i) ? (
<div
key={f.id}
className={`kp-lage${i === aktiv ? " is-aktiv" : ""}`}
aria-hidden={i !== aktiv || undefined}
>
{f.lang ? (
<div className="kp-lang">
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "(min-width: 1200px) 340px, 30vw", 4978)} />
</div>
) : (
<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}
>
{/* Website-Analyse: statt der Adresse (steht in der Safari-Leiste) der Bedienhinweis, damit es eine Zeile bleibt;
ohne Maus (Tablet quer) heißt es „Tippen Sie“ statt „Zeigen Sie mit der Maus“ */}
{f.bedienhinweis
? `Erfundenes Beispiel · ${fein ? f.bedienhinweis.maus : f.bedienhinweis.tippen}`
: 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>
</div>
);
};
// ── Handy: Wisch-Reihe ───────────────────────────────────────────────────────
/** Karte "Als App": der lange Screenshot schiebt sich langsam nach oben, solange die Karte sichtbar ist. */
const LangeKarte = ({ f }: { f: Funktion }) => {
const ref = useRef<HTMLDivElement>(null);
const [sichtbar, setSichtbar] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const beobachter = new IntersectionObserver(([e]) => setSichtbar(e.isIntersecting), { threshold: 0.6 });
beobachter.observe(el);
return () => beobachter.disconnect();
}, []);
return (
<div className="kp-karte" ref={ref} data-sichtbar={sichtbar ? "ja" : "nein"}>
<HandyRahmen className="kp-karte__handy">
<div className="kp-lang">
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "250px", 4978)} />
</div>
</HandyRahmen>
<h3 className="wk-h3 kp-karte__titel">{f.titel}</h3>
<KartenUnterschrift f={f} />
</div>
);
};
/** 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>
<KartenUnterschrift f={f} />
<p className="kp-karte__beispiel">{beispielText(f)}</p>
{f.bedienhinweis ? <p className="kp-karte__bedienen">{f.bedienhinweis.tippen}</p> : null}
{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="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>
<KartenUnterschrift f={f} />
</div>
);
})}
</WischReihe>
</div>
);
// ── Schrittzeile ──────────────────────────────────────────────────────────────
const Schrittzeile = ({ desktop }: { desktop: boolean }) =>
desktop ? (
<div className="wk-container">
<ol className="kp-schritte wk-reveal" aria-label="Ablauf in vier Schritten">
{SCHRITTE.map((s) => (
<li key={s.nr} className="kp-schritt">
<span className="kp-schritt__nr wk-num">{s.nr}</span>
<span className="kp-schritt__text">{s.text}</span>
</li>
))}
</ol>
</div>
) : (
<div className="kp-schritte-handy">
<WischReihe ariaLabel="Ablauf in vier Schritten, seitlich wischen" className="kp-schritte-reihe">
{SCHRITTE.map((s) => (
<div key={s.nr} className="kp-schritt-karte">
<span className="kp-schritt__nr wk-num">{s.nr}</span>
<span className="kp-schritt__text">{s.text}</span>
</div>
))}
</WischReihe>
</div>
);
// ── Abschnitt ─────────────────────────────────────────────────────────────────
const Kundenportal = () => {
const desktop = useMedia("(min-width: 900px)");
return (
<section id="kundenportal" className="wk-section wk-section--alt kp" aria-labelledby="kundenportal-titel">
<div className="wk-container kp__kopf wk-reveal">
<h2 id="kundenportal-titel" className="wk-h2">
{/* zwei Sätze, zwei Zeilen: der Umbruch soll nach "Projekt." fallen, nicht mitten im zweiten Satz */}
<span className="kp-satz">Ihr Projekt.</span> <span className="kp-satz">Live im Kundenportal.</span>
</h2>
<p className="wk-lead">{EINLEITUNG}</p>
</div>
{desktop ? <Buehne /> : <HandyReihe />}
<Schrittzeile desktop={desktop} />
</section>
);
};
export default Kundenportal;