- 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>
779 lines
35 KiB
TypeScript
779 lines
35 KiB
TypeScript
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;
|