diff --git a/src/components/home/Kundenportal.tsx b/src/components/home/Kundenportal.tsx
index db94972..e388ac2 100644
--- a/src/components/home/Kundenportal.tsx
+++ b/src/components/home/Kundenportal.tsx
@@ -174,7 +174,10 @@ 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 = "Weitere Funktionen";
+/** Ü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) ──────────────────────
@@ -424,27 +427,54 @@ const Buehne = () => {
;
- // die drei Funktionen stehen in einer Gruppe mit eigener Überschrift
- const erste = !FUNKTIONEN[i - 1]?.demo;
+ if (!f.demo) {
+ return (
+
+
+
+ );
+ }
+ // 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 (
-
- {erste ?
{GRUPPE_TITEL}
: null}
- {eintrag}
+
+
+
+ {DEMO_INDIZES.map((d) => (
+
+
+
+ ))}
+
);
})}
diff --git a/src/components/home/demos/heatmap/HeatmapSeite.tsx b/src/components/home/demos/heatmap/HeatmapSeite.tsx
index 47b8ee2..eae6b27 100644
--- a/src/components/home/demos/heatmap/HeatmapSeite.tsx
+++ b/src/components/home/demos/heatmap/HeatmapSeite.tsx
@@ -318,9 +318,9 @@ const useGemessen = (ref: RefObject, variante: DemoVariante)
/**
* 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.
+ * die Dichte steigt darum steil mit dem Klickanteil (Tageskarte = 1, nur sie wird durchgehend rot).
+ * Jede Zone bekommt aber mindestens so viele Punkte, dass ihre Klicks sichtbar sind (Wunsch des Inhabers 2026-10-04:
+ * nicht nur Punkte auf „Tageskarte kaufen“, auch z. B. die Telefonnummer). Große Zonen bekommen mehr Punkte.
*/
const punktAnzahl = (zone: Zone, r: PunktRechteck, variante: DemoVariante) => {
const anteil = zone.klicks / TOP_ZONE.klicks;
@@ -328,7 +328,8 @@ const punktAnzahl = (zone: Zone, r: PunktRechteck, variante: DemoVariante) => {
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)));
+ const minimum = Math.round(zone.klicks / MINDEST_KLICKS_JE_PUNKT[variante]);
+ return Math.min(obergrenze, Math.max(minimum, Math.round(anteil ** 1.6 * 8 * flecke)));
};
/** Alle Klickpunkte (Beispieldaten, feste Saat) je Zone, relativ zur Fläche. 85 % in der Zone, 15 % als Streuung am Rand. */
@@ -367,6 +368,8 @@ const erzeugePunkte = (rechtecke: Rechtecke, variante: DemoVariante): Record `${p}\u00a0% der Klicks gehen auf die Telefonnummer im Kopf der Seite.`,
},
{
@@ -86,7 +86,7 @@ const ROH: Rohzone[] = [
{
id: "oeffnungszeiten",
name: "Öffnungszeiten",
- klicks: 96,
+ klicks: 61,
satz: (p) => `Die Öffnungszeiten bekommen ${p}\u00a0% der Klicks.`,
},
];
diff --git a/src/components/home/kundenportal.css b/src/components/home/kundenportal.css
index 5ea81b8..88ff2e9 100644
--- a/src/components/home/kundenportal.css
+++ b/src/components/home/kundenportal.css
@@ -176,11 +176,8 @@
.kp-eintrag__punkt {
top: 15px;
}
- .kp-gruppe[data-erste="ja"] {
- margin-top: 6px;
- }
- .kp-gruppe__titel {
- padding-top: 6px;
+ .kp-untereintrag {
+ padding: 4px 0;
}
}
/* sehr niedrige Fenster: nur die aktive Funktion zeigt ihre Unterschrift (acht Stationen brauchen sonst mehr Platz) */
@@ -194,22 +191,79 @@
}
}
-/* Gruppe "Weitere Funktionen": die drei Demo-Stationen mit kleiner Überschrift darüber */
-.kp-gruppe[data-erste="ja"] {
- margin-top: 10px;
+/* Überpunkt "Funktionen": die drei Demo-Stationen klappen darunter auf, sobald eine von ihnen aktiv ist.
+ Die Liste steht oben in der Bühne (nicht mittig), damit beim Aufklappen nur das folgt, was darunter liegt. */
+.kp-liste {
+ align-self: start;
+ padding-top: clamp(0px, 4svh, 40px);
}
-.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;
+.kp-gruppe[data-offen="ja"] .kp-eintrag--gruppe .kp-eintrag__titel {
+ color: var(--fg);
+}
+.kp-unterliste {
+ display: flex;
+ flex-direction: column;
+ margin: 2px 0 4px;
+ padding-left: 14px;
+ border-left: 1px solid var(--line-strong);
+}
+.kp-unterliste[hidden] {
+ display: none;
+}
+.kp-untereintrag {
+ position: relative;
+ display: block;
+ width: 100%;
+ min-height: 36px;
+ padding: 6px 0;
+ text-align: left;
color: var(--fg-muted);
+ -webkit-tap-highlight-color: transparent;
}
-.kp-gruppe .kp-eintrag__titel {
- font-size: 1.1875rem;
+.kp-untereintrag__titel {
+ display: block;
+ font-size: 1.125rem;
+ font-weight: 600;
+ line-height: 1.2;
+ transition: color var(--dur-state) var(--ease);
+}
+.kp-untereintrag[aria-current="step"] .kp-untereintrag__titel {
+ color: var(--fg);
+}
+/* derselbe rote Punkt wie bei den Hauptpunkten, in derselben Spalte (14 px Einzug + 1 px Linie weiter links) */
+.kp-untereintrag .kp-eintrag__punkt {
+ left: -35px;
+ top: 12px;
+}
+.kp-untereintrag[aria-current="step"] .kp-eintrag__punkt {
+ opacity: 1;
+ transform: none;
+}
+@media (hover: hover) and (pointer: fine) {
+ .kp-untereintrag:hover .kp-untereintrag__titel {
+ color: var(--fg);
+ }
+}
+@media (prefers-reduced-motion: no-preference) {
+ .kp-unterliste:not([hidden]) > li {
+ animation: kp-aufklappen var(--dur-enter) var(--ease) both;
+ }
+ .kp-unterliste:not([hidden]) > li:nth-child(2) {
+ animation-delay: 60ms;
+ }
+ .kp-unterliste:not([hidden]) > li:nth-child(3) {
+ animation-delay: 120ms;
+ }
+}
+@keyframes kp-aufklappen {
+ from {
+ opacity: 0;
+ transform: translateY(-6px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
}
/* Geräte: Desktop-Fenster mit überlappendem Handy-Rahmen unten rechts */
diff --git a/src/test/relaunch-kundenportal.test.tsx b/src/test/relaunch-kundenportal.test.tsx
index 88a9568..1eb6b30 100644
--- a/src/test/relaunch-kundenportal.test.tsx
+++ b/src/test/relaunch-kundenportal.test.tsx
@@ -101,21 +101,25 @@ describe("Kundenportal: Text und Reihenfolge", () => {
expect(screen.queryByText(/mit der Maus/)).toBeNull();
});
- it("Desktop: die Liste der Bühne hat dieselben acht Funktionen in derselben Reihenfolge, die drei Demos als Gruppe", () => {
+ it("Desktop: die Liste hat die Portal-Stationen und den Überpunkt „Funktionen“, der zugeklappt beginnt", () => {
zeigeDesktop();
const liste = screen.getByRole("list", { name: "Funktionen des Kundenportals" });
const eintraege = within(liste).getAllByRole("button");
- expect(eintraege).toHaveLength(8);
- TITEL.forEach((titel, i) => {
- expect(eintraege[i]).toHaveTextContent(new RegExp(`^${titel}`));
- for (const text of [UNTERSCHRIFTEN[i]].flat()) expect(eintraege[i]).toHaveTextContent(text);
- });
- // Gruppenüberschrift steht über dem ersten der drei Funktions-Einträge, die Gruppe umfasst genau diese drei
- const gruppe = screen.getByText("Weitere Funktionen");
- 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");
+ const oben = ["Live-Vorschau", "Fortschritt", "Chat", "Rechnungen", "Funktionen", "Als App"];
+ expect(eintraege).toHaveLength(oben.length);
+ oben.forEach((titel, i) => expect(eintraege[i]).toHaveTextContent(new RegExp(`^${titel}`)));
+ // Portal-Stationen behalten ihren Satz
+ [0, 1, 2, 3].forEach((i) => expect(eintraege[i]).toHaveTextContent(UNTERSCHRIFTEN[i] as string));
+ expect(eintraege[5]).toHaveTextContent(UNTERSCHRIFTEN[7] as string);
+ // Überpunkt: zugeklappt, die drei Unterpunkte (nur Titel, keine Stichpunkte) sind versteckt
+ const gruppe = eintraege[4];
+ expect(gruppe).toHaveAttribute("aria-expanded", "false");
+ const unterliste = liste.querySelector("#kp-unterliste")!;
+ expect(unterliste).toHaveAttribute("aria-label", "Funktionen");
+ expect(unterliste).not.toBeVisible();
+ const unter = within(unterliste).getAllByRole("button", { hidden: true });
+ expect(unter.map((b) => b.textContent)).toEqual(["Terminplaner", "Website-Analyse", "News-Pinnwand"]);
+ expect(liste).not.toHaveTextContent("Heatmap: wohin Besucher klicken");
});
it("Tablet quer (Bühne ohne Maus): der Bedienhinweis der Website-Analyse sagt „Tippen Sie“ statt „Zeigen Sie mit der Maus“", () => {
@@ -308,6 +312,22 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
.find((b) => b.getAttribute("aria-current") === "step")
?.textContent;
+ it("der Überpunkt „Funktionen“ klappt auf, sobald eine der drei Funktionen erreicht ist, und danach wieder zu", () => {
+ beobachterAttrappe();
+ zeigeDesktop();
+ const gruppe = screen.getByRole("button", { name: /^Funktionen/ });
+ expect(gruppe).toHaveAttribute("aria-expanded", "false");
+ meldeMarke(4);
+ expect(gruppe).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByRole("list", { name: "Funktionen" })).toBeVisible();
+ expect(aktiverEintrag()).toBe("Terminplaner");
+ meldeMarke(5);
+ expect(aktiverEintrag()).toBe("Website-Analyse");
+ meldeMarke(7);
+ expect(gruppe).toHaveAttribute("aria-expanded", "false");
+ expect(aktiverEintrag()).toMatch(/^Als App/);
+ });
+
it("die aktive Funktion ist hervorgehoben, die Bilder folgen, bei Schritt 8 blendet das Fenster aus", () => {
beobachterAttrappe();
const { container } = zeigeDesktop();