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:
rootandClaude Opus 5.5 committed 2026-10-04 14:24:59 +00:00
1 parent c9ad9ba2b7
commit 72275db9fa
39 files changed
+6567 -75

No files matched your search

+335 -31
View File
@@ -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>
);