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>
This commit is contained in:
1 parent
f0bced164b
commit
9914002e74
5 files changed
+164
-57
No files matched your search
@@ -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 = () => {
|
||||
<div className="wk-container kp-buehne__innen">
|
||||
<ol className="kp-liste" aria-label="Funktionen des Kundenportals">
|
||||
{FUNKTIONEN.map((f, i) => {
|
||||
const eintrag = (
|
||||
<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>
|
||||
);
|
||||
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;
|
||||
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={f.id} className="kp-gruppe" data-erste={erste ? "ja" : undefined}>
|
||||
{erste ? <p className="kp-gruppe__titel">{GRUPPE_TITEL}</p> : null}
|
||||
{eintrag}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -318,9 +318,9 @@ const useGemessen = (ref: RefObject<HTMLElement | null>, 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<str
|
||||
};
|
||||
|
||||
const KLICKS_JE_PUNKT = { desktop: 4, handy: 7 } as const;
|
||||
/** Untergrenze: höchstens so viele Klicks je gezeichnetem Punkt (Telefon 96 Klicks = 7 Punkte auf dem MacBook). */
|
||||
const MINDEST_KLICKS_JE_PUNKT = { desktop: 14, handy: 24 } as const;
|
||||
|
||||
const LEINWAND = { desktop: { b: 756, h: 447, radius: 19 }, handy: { b: 201, h: 437, radius: 9 } } as const;
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ const ROH: Rohzone[] = [
|
||||
{
|
||||
id: "telefon",
|
||||
name: "Telefonnummer",
|
||||
klicks: 61,
|
||||
klicks: 96,
|
||||
satz: (p) => `${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.`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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<HTMLElement>("#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();
|
||||
|
||||
Reference in new issue
Block a user