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 = () => {
    {FUNKTIONEN.map((f, i) => { - const eintrag = ( - - ); - if (!f.demo) return
  1. {eintrag}
  2. ; - // die drei Funktionen stehen in einer Gruppe mit eigener Überschrift - const erste = !FUNKTIONEN[i - 1]?.demo; + if (!f.demo) { + return ( +
  3. + +
  4. + ); + } + // 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 ( -
  5. - {erste ?

    {GRUPPE_TITEL}

    : null} - {eintrag} +
  6. + +
  7. ); })} 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();