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

@@ -0,0 +1,691 @@
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type MouseEvent, type RefObject } from "react";
import { DemoZeiger, useFeinerZeiger, zufall, type DemoVariante } from "../gemeinsam";
import { punkteInZone, zeichneHeatmap, type HeatPunkt } from "../heat";
import { HEATMAP_START, type HeatmapZustand } from "./ablauf";
import { FLAECHE_GROESSE, TOP_ZONE, ZONEN, ZONEN_LAYOUT, schildText, zoneNach, type Zone, type PunktRechteck } from "./zonen";
import "./heatmap.css";
/**
* Die erfundene Beispiel-Website „Boulderhalle Überhang“ (Demo Website-Analyse, Spezifikation 3.2) mit der
* Heatmap darüber. 'desktop' wird in 1512 x 893 gesetzt (MacBook), 'handy' in 402 x 874 (iPhone, oben 59 und
* unten 34 frei). Die Seite ist reine Dekoration: aria-hidden, keine fokussierbaren Elemente.
* Alle Bilder sind selbst gezeichnete SVG/CSS-Grafiken, nichts wird nachgeladen.
*/
/** `zustand` fehlt, solange der Taktgeber noch lädt: dann steht die Website im Anfangszustand da. */
type Props = { zustand?: HeatmapZustand; variante: DemoVariante };
// ── Routenbild: selbst gezeichnete Kletterwand ────────────────────────────────
type Farbe = "lime" | "orange" | "pink" | "cyan";
const FARBEN: Record<Farbe, { hell: string; dunkel: string }> = {
lime: { hell: "#c6f432", dunkel: "#8aa81a" },
orange: { hell: "#ff7a1a", dunkel: "#b8500a" },
pink: { hell: "#ff4f9a", dunkel: "#b82a6b" },
cyan: { hell: "#2ad1ff", dunkel: "#0e86ad" },
};
/** Formen von Klettergriffen um den Nullpunkt (etwa 70 breit). */
const FORMEN: Record<string, string> = {
a: "M-30 8C-35-14-10-33 14-29C35-25 38-2 30 14C22 31-25 33-30 8Z",
b: "M-35 2C-31-25 31-27 35 1C37 22 21 30 0 30C-22 30-37 23-35 2Z",
c: "M-37-8Q0-24 37-8Q42 13 0 15Q-42 13-37-8Z",
d: "M0-27C16-27 28-15 28 0C28 15 16 27 0 27C-16 27-28 15-28 0C-28-15-16-27 0-27Z",
e: "M-25 20C-33-4-8-31 14-27C35-23 31 8 23 22C9 31-14 31-25 20Z",
};
/** [x, y, Form, Größe, Drehung]; x und y als Anteil der Wand (0 bis 1). */
type Griff = readonly [number, number, string, number, number];
const ROUTEN: { farbe: Farbe; grad: string; griffe: readonly Griff[] }[] = [
{
farbe: "lime",
grad: "4",
griffe: [
[0.14, 0.84, "b", 1.1, -8],
[0.22, 0.72, "a", 0.9, 20],
[0.13, 0.6, "c", 1, -15],
[0.24, 0.47, "e", 0.95, 35],
[0.15, 0.34, "a", 0.9, -30],
[0.26, 0.22, "d", 0.9, 0],
[0.18, 0.1, "b", 1.15, 10],
],
},
{
farbe: "orange",
grad: "5",
griffe: [
[0.36, 0.86, "c", 1, 10],
[0.44, 0.74, "a", 1, -20],
[0.34, 0.62, "b", 0.9, 15],
[0.47, 0.5, "e", 1, -40],
[0.38, 0.37, "d", 0.85, 0],
[0.5, 0.25, "a", 0.95, 25],
[0.42, 0.12, "b", 1.1, -5],
],
},
{
farbe: "pink",
grad: "6",
griffe: [
[0.6, 0.82, "a", 1, 5],
[0.68, 0.71, "e", 0.9, -25],
[0.57, 0.58, "c", 1.05, 20],
[0.7, 0.46, "b", 1, -10],
[0.61, 0.34, "a", 0.9, 40],
[0.73, 0.22, "d", 0.9, 0],
[0.64, 0.1, "c", 1.1, -12],
],
},
{
farbe: "cyan",
grad: "7",
griffe: [
[0.83, 0.85, "b", 1, -5],
[0.9, 0.72, "a", 0.9, 30],
[0.79, 0.6, "e", 1, -15],
[0.91, 0.48, "c", 0.95, 10],
[0.8, 0.36, "a", 1, -35],
[0.9, 0.24, "b", 0.9, 15],
[0.84, 0.11, "d", 1.1, 0],
],
},
];
/** Kleine graue Tritte zwischen den Routen: [x, y, Form, Größe, Drehung]. */
const TRITTE: readonly Griff[] = [
[0.06, 0.66, "d", 0.4, 0],
[0.31, 0.8, "c", 0.45, 20],
[0.52, 0.64, "d", 0.4, 0],
[0.47, 0.88, "d", 0.42, 0],
[0.77, 0.77, "c", 0.45, -15],
[0.96, 0.62, "d", 0.4, 0],
[0.31, 0.5, "d", 0.38, 0],
[0.55, 0.4, "c", 0.45, 30],
[0.74, 0.4, "d", 0.38, 0],
[0.07, 0.4, "c", 0.45, -20],
[0.34, 0.2, "d", 0.38, 0],
[0.56, 0.2, "d", 0.4, 0],
[0.96, 0.34, "c", 0.45, 25],
[0.06, 0.2, "d", 0.38, 0],
];
const Griffe = ({ griff, farbe, mass }: { griff: Griff; farbe: Farbe | "grau"; mass: number }) => {
const [x, y, form, groesse, drehung] = griff;
const f = farbe === "grau" ? { hell: "#b9b6ae", dunkel: "#7d7a73" } : FARBEN[farbe];
const pfad = FORMEN[form];
const g = groesse * mass;
return (
<g transform={`translate(${x} ${y})`}>
<path d={pfad} transform={`translate(${5 * g} ${8 * g}) rotate(${drehung}) scale(${g})`} fill="#000" opacity="0.38" />
<g transform={`rotate(${drehung}) scale(${g})`}>
<path d={pfad} fill={f.dunkel} transform="translate(0 4)" />
<path d={pfad} fill={f.hell} />
<ellipse cx="-9" cy="-10" rx="13" ry="6" fill="#fff" opacity="0.38" transform="rotate(-24)" />
</g>
</g>
);
};
/** Die Wand als SVG. b und h sind die Maße der Zeichnung, `klein` ist die Handy-Fassung (weniger, kleinere Griffe). */
const Routenwand = ({ b, h, klein }: { b: number; h: number; klein?: boolean }) => {
const id = useId().replace(/[^a-zA-Z0-9]/g, "");
// flache Wand (Desktop, 260 hoch): kleinere Griffe, sonst drängen sie sich
const mass = klein ? 0.5 : h < 300 ? 0.68 : 1;
const px = (x: number) => x * b;
const py = (y: number) => h * (0.17 + (y - 0.1) * 0.9);
const matte = h * 0.93;
return (
<svg
className="hm-wand__svg"
viewBox={`0 0 ${b} ${h}`}
preserveAspectRatio="xMidYMid slice"
focusable="false"
aria-hidden="true"
>
<defs>
<linearGradient id={`${id}w`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#3b3b41" />
<stop offset="1" stopColor="#222226" />
</linearGradient>
<pattern id={`${id}p`} width={klein ? 16 : 32} height={klein ? 16 : 32} patternUnits="userSpaceOnUse">
<circle cx={klein ? 8 : 16} cy={klein ? 8 : 16} r={klein ? 1.3 : 2.6} fill="#151517" />
</pattern>
</defs>
<rect width={b} height={h} fill={`url(#${id}w)`} />
<rect width={b} height={h} fill={`url(#${id}p)`} opacity="0.9" />
{/* Plattenfugen */}
<g stroke="#141416" strokeWidth={klein ? 1.5 : 3}>
<path d={`M${b * 0.25} 0V${matte}M${b * 0.5} 0V${matte}M${b * 0.75} 0V${matte}M0 ${h * 0.5}H${b}`} />
</g>
<g stroke="#4a4a52" strokeWidth={klein ? 0.8 : 1.5} opacity="0.6">
<path
d={`M${b * 0.25 + 3} 0V${matte}M${b * 0.5 + 3} 0V${matte}M${b * 0.75 + 3} 0V${matte}M0 ${h * 0.5 + 3}H${b}`}
/>
</g>
{/* Dach: die Wand hängt über */}
<path d={`M0 0H${b}V${h * 0.05}L0 ${h * 0.12}Z`} fill="#18181b" />
<path d={`M0 ${h * 0.12}L${b} ${h * 0.05}`} stroke="#4a4a52" strokeWidth={klein ? 1 : 2} />
{/* Matte */}
<rect y={matte} width={b} height={h - matte} fill="#1b2633" />
<path d={`M0 ${matte}H${b}`} stroke="#35506b" strokeWidth={klein ? 1.5 : 3} />
<path
d={`M${b * 0.2} ${matte}V${h}M${b * 0.4} ${matte}V${h}M${b * 0.6} ${matte}V${h}M${b * 0.8} ${matte}V${h}`}
stroke="#121a24"
strokeWidth={klein ? 1 : 2}
/>
{/* Tritte */}
{TRITTE.filter((_, i) => !klein || i % 2 === 0).map((t, i) => (
<Griffe key={`t${i}`} griff={[px(t[0]), py(t[1]), t[2], t[3], t[4]]} farbe="grau" mass={mass} />
))}
{ROUTEN.map((r) => {
const griffe = klein ? r.griffe.filter((_, i) => i % 2 === 0 || i === r.griffe.length - 1) : r.griffe;
const start = griffe[0];
const oben = griffe[griffe.length - 1];
return (
<g key={r.farbe}>
{griffe.map((g, i) => (
<g key={i}>
{/* Kreidespur */}
<ellipse
cx={px(g[0]) - 8 * mass}
cy={py(g[1]) - 14 * mass}
rx={30 * mass}
ry={15 * mass}
fill="#fff"
opacity="0.07"
/>
<Griffe griff={[px(g[0]), py(g[1]), g[2], g[3], g[4]]} farbe={r.farbe} mass={mass} />
</g>
))}
{/* Schwierigkeit am Startgriff */}
<g transform={`translate(${px(start[0]) - 54 * mass} ${py(start[1]) + 6 * mass})`}>
<circle r={19 * mass} fill="#f3efe6" />
<circle r={19 * mass} fill="none" stroke={FARBEN[r.farbe].hell} strokeWidth={5 * mass} />
<text
textAnchor="middle"
dominantBaseline="central"
fontSize={22 * mass}
fontWeight="700"
fill="#141414"
fontFamily="inherit"
>
{r.grad}
</text>
</g>
{/* Ring um den Zielgriff */}
<circle
cx={px(oben[0])}
cy={py(oben[1])}
r={46 * mass}
fill="none"
stroke={FARBEN[r.farbe].hell}
strokeWidth={3 * mass}
strokeDasharray={`${9 * mass} ${9 * mass}`}
opacity="0.8"
/>
</g>
);
})}
<rect width={b} height={h} fill="none" />
</svg>
);
};
// ── Kleine Zeichnungen ───────────────────────────────────────────────────────
const LogoZeichen = () => (
<svg className="hm-logo__zeichen" viewBox="0 0 56 56" aria-hidden="true" focusable="false">
<rect width="56" height="56" rx="16" fill="#c6f432" />
<path d="M13 44V22L33 11L44 19V44Z" fill="#141414" />
<path d="M13 22L33 11" stroke="#c6f432" strokeWidth="2" opacity="0.5" />
<circle cx="25" cy="33" r="4.5" fill="#ff7a1a" />
<circle cx="35" cy="24" r="3.6" fill="#ff4f9a" />
<circle cx="33" cy="38" r="3.4" fill="#2ad1ff" />
</svg>
);
const Pfeil = () => (
<svg className="hm-pfeil" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M5 12h14M13 6l6 6-6 6" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const Handset = () => (
<svg className="hm-tel__symbol" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="6.5" y="2.5" width="11" height="19" rx="3" fill="none" stroke="currentColor" strokeWidth="2" />
<path d="M10.5 18.5h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
// ── Messen und Klickpunkte ────────────────────────────────────────────────────
type Rechtecke = Record<string, PunktRechteck>;
const gleich = (a: Rechtecke, b: Rechtecke) =>
Object.keys(b).length === Object.keys(a).length &&
Object.entries(b).every(([id, r]) => {
const alt = a[id];
return (
alt && Math.abs(alt.x - r.x) < 0.5 && Math.abs(alt.y - r.y) < 0.5 && Math.abs(alt.b - r.b) < 0.5 && Math.abs(alt.h - r.h) < 0.5
);
});
/**
* Misst die Zonen-Rechtecke relativ zur Fläche in Designpunkten. getBoundingClientRect liefert Bildschirmpunkte;
* der Maßstab des Rahmens (transform: scale) ergibt sich aus Bildschirmbreite / Layoutbreite der Fläche.
* Ohne Messung (jsdom, ausgeblendet) gelten die festen Ersatz-Rechtecke.
*/
const useGemessen = (ref: RefObject<HTMLElement | null>, variante: DemoVariante) => {
const [rechtecke, setRechtecke] = useState<Rechtecke>(ZONEN_LAYOUT[variante]);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
let aktiv = true;
const messe = () => {
if (!aktiv) return;
const fl = el.getBoundingClientRect();
if (!fl.width || !el.offsetWidth) return;
const k = fl.width / el.offsetWidth;
const neu: Rechtecke = {};
el.querySelectorAll<HTMLElement>("[data-zone]").forEach((z) => {
const r = z.getBoundingClientRect();
neu[z.dataset.zone as string] = {
x: (r.left - fl.left) / k,
y: (r.top - fl.top) / k,
b: r.width / k,
h: r.height / k,
};
});
if (Object.keys(neu).length === 0) return;
setRechtecke((alt) => (gleich(alt, neu) ? alt : neu));
};
messe();
const beobachter = typeof ResizeObserver === "function" ? new ResizeObserver(messe) : null;
beobachter?.observe(el);
// Die Schrift lädt später und verschiebt Zeilenumbrüche: dann neu messen
document.fonts?.ready.then(messe).catch(() => {});
window.addEventListener("resize", messe);
return () => {
aktiv = false;
beobachter?.disconnect();
window.removeEventListener("resize", messe);
};
}, [ref, variante]);
return rechtecke;
};
/**
* 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.
*/
const punktAnzahl = (zone: Zone, r: PunktRechteck, variante: DemoVariante) => {
const anteil = zone.klicks / TOP_ZONE.klicks;
// Fläche eines Flecks in Designpunkten (Leinwand = halbe Fläche, der Radius gilt in Leinwandpunkten)
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)));
};
/** Alle Klickpunkte (Beispieldaten, feste Saat) je Zone, relativ zur Fläche. 85 % in der Zone, 15 % als Streuung am Rand. */
const erzeugePunkte = (rechtecke: Rechtecke, variante: DemoVariante): Record<string, HeatPunkt[]> => {
const { breite, hoehe } = FLAECHE_GROESSE[variante];
const aus: Record<string, HeatPunkt[]> = {};
ZONEN.forEach((zone, i) => {
const r = rechtecke[zone.id] ?? ZONEN_LAYOUT[variante][zone.id];
const rand = Math.min(r.b, r.h) * 0.3;
const zufallsfolge = zufall(1000 + i * 17 + (variante === "handy" ? 5 : 0));
const anzahl = punktAnzahl(zone, r, variante);
const kern = Math.round(anzahl * 0.85);
const innen = punkteInZone({ x: r.x / breite, y: r.y / hoehe, b: r.b / breite, h: r.h / hoehe }, kern, zufallsfolge);
const streu = punkteInZone(
{
x: (r.x - rand) / breite,
y: (r.y - rand) / hoehe,
b: (r.b + 2 * rand) / breite,
h: (r.h + 2 * rand) / hoehe,
},
anzahl - kern,
zufallsfolge,
);
// mischen, damit jede Teilmenge (die ersten n Punkte) gleichmäßig über die Zone verteilt ist
const alle = [...innen, ...streu].map((p) => ({
x: Math.min(1, Math.max(0, p.x)),
y: Math.min(1, Math.max(0, p.y)),
}));
for (let n = alle.length - 1; n > 0; n--) {
const m = Math.floor(zufallsfolge() * (n + 1));
[alle[n], alle[m]] = [alle[m], alle[n]];
}
aus[zone.id] = alle;
});
return aus;
};
const KLICKS_JE_PUNKT = { desktop: 4, handy: 7 } as const;
const LEINWAND = { desktop: { b: 756, h: 447, radius: 19 }, handy: { b: 201, h: 437, radius: 9 } } as const;
// ── Seite ───────────────────────────────────────────────────────────────────
const HeatmapSeite = ({ zustand = HEATMAP_START, variante }: Props) => {
const wurzel = useRef<HTMLDivElement>(null);
const leinwand = useRef<HTMLCanvasElement>(null);
const fein = useFeinerZeiger();
const rechtecke = useGemessen(wurzel, variante);
const punkte = useMemo(() => erzeugePunkte(rechtecke, variante), [rechtecke, variante]);
const { fuellung, statisch } = zustand;
// Neu zeichnen nur, wenn sich die sichtbaren Punkte ändern (neuer Klick, neue Messung), nicht je Bild
useEffect(() => {
const c = leinwand.current;
if (!c) return;
const sichtbar: HeatPunkt[] = [];
for (const z of ZONEN) {
const alle = punkte[z.id];
const anzahl = Math.round((fuellung[z.id] ?? 0) * alle.length);
if (anzahl > 0) sichtbar.push(...alle.slice(0, anzahl));
}
zeichneHeatmap(c, sichtbar, LEINWAND[variante].radius);
}, [punkte, fuellung, variante]);
const zoneUnter = (e: MouseEvent) => {
const z = (e.target as Element).closest?.("[data-zone]");
return z?.getAttribute("data-zone") ?? null;
};
const bewegt = (e: MouseEvent) => {
if (!zustand.maus) zustand.setzeMaus(true);
const id = zoneUnter(e);
if (id !== zustand.auswahl) zustand.waehle(id);
};
const verlassen = () => {
zustand.setzeMaus(false);
zustand.waehle(null);
};
const getippt = (e: MouseEvent) => {
const id = zoneUnter(e);
// Maus: wählen. Finger: wählen, nochmal tippen hebt die Auswahl auf.
zustand.waehle(!fein && id !== null && id === zustand.auswahl ? null : id);
};
const gewaehlt = zoneNach(zustand.auswahl);
const r = gewaehlt ? rechtecke[gewaehlt.id] : undefined;
const { breite } = FLAECHE_GROESSE[variante];
const zeiger = zustand.zeiger[variante];
// Schildchen über der Zone; im Kopf der Seite und auf dem Handy in der oberen Hälfte darunter (sonst verdeckt es die Überschrift)
// Große Zonen (das Routenbild) tragen das Schildchen im Inneren, sonst würde es Nachbarn verdecken.
const innen = r ? r.h >= (variante === "desktop" ? 250 : 140) : false;
const unten = !innen && r ? (variante === "desktop" ? r.y < 80 : r.y < 440) : false;
const lage = innen ? "innen" : unten ? "unter" : "ueber";
return (
<div
ref={wurzel}
className={`hm hm-seite hm-${variante}`}
data-variante={variante}
data-phase={zustand.phase}
data-laeuft={zustand.laeuft ? "ja" : "nein"}
aria-hidden="true"
onMouseMove={fein ? bewegt : undefined}
onMouseLeave={fein ? verlassen : undefined}
onClick={getippt}
>
{variante === "desktop" ? <Desktop /> : <Handy />}
<canvas
ref={leinwand}
className="hm-heat"
width={LEINWAND[variante].b}
height={LEINWAND[variante].h}
data-anteil={zustand.punkte}
/>
{gewaehlt && r ? (
<>
<div
key={`rahmen-${gewaehlt.id}`}
className="hm-rahmen"
style={{ left: r.x, top: r.y, width: r.b, height: r.h }}
/>
<div
key={`schild-${gewaehlt.id}`}
className="hm-schild"
data-lage={lage}
style={{
top: innen ? r.y + (variante === "desktop" ? 28 : 10) : unten ? r.y + r.h + 14 : r.y - 14,
...(r.x + r.b / 2 > breite * 0.55
? { right: breite - (r.x + r.b) + (innen ? (variante === "desktop" ? 28 : 10) : 0) }
: { left: r.x + (innen ? (variante === "desktop" ? 28 : 10) : 0) }),
}}
>
{schildText(gewaehlt)}
</div>
</>
) : null}
{statisch ? null : (
<DemoZeiger
x={zeiger.x}
y={zeiger.y}
klick={zeiger.klick}
sichtbar={zeiger.sichtbar}
art={variante === "handy" ? "finger" : "maus"}
groesse={variante === "handy" ? 1 : 1.9}
/>
)}
</div>
);
};
// ── Desktop-Fassung (1512 x 893) ─────────────────────────────────────────────
const Desktop = () => (
<>
<div className="hm-kopf">
<div className="hm-logo" data-zone="logo">
<LogoZeichen />
<span className="hm-logo__text">
<span className="hm-logo__klein">Boulderhalle</span>
<span className="hm-logo__gross">Überhang</span>
</span>
</div>
<div className="hm-nav">
<span className="hm-nav__punkt" data-zone="nav-routen">
Routen
</span>
<span className="hm-nav__punkt" data-zone="nav-kurse">
Kurse
</span>
<span className="hm-nav__punkt">Preise</span>
<span className="hm-nav__punkt">Öffnungszeiten</span>
</div>
<div className="hm-tel" data-zone="telefon">
<Handset />
<span>0221 4710 142</span>
</div>
</div>
<div className="hm-held">
<div className="hm-held__vor">
<span className="hm-held__punkt" />
Frisch geschraubt
</div>
<div className="hm-held__titel">
<span>
Heute <em>14</em>
</span>
<span>neue Boulder</span>
</div>
<div className="hm-knopf" data-zone="tageskarte">
<span>Tageskarte kaufen</span>
<Pfeil />
</div>
</div>
<div className="hm-wand" data-zone="routenbild">
<Routenwand b={640} h={260} />
</div>
{/* Öffnungszeiten liegen unter der Wand und links vom iPhone der Bühne (das deckt x > 1158 / y > 419 der Fläche) */}
<div className="hm-karte hm-oeffnung" data-zone="oeffnungszeiten">
<div className="hm-karte__titel">Öffnungszeiten</div>
<div className="hm-preise">
<div>
<span>Mo–Fr</span>
<b>10–23 Uhr</b>
</div>
<div>
<span>Sa, So</span>
<b>9–21 Uhr</b>
</div>
</div>
</div>
<div className="hm-karten">
<div className="hm-karte hm-karte--live" data-zone="auslastung">
<div className="hm-karte__kopf">
<span className="hm-live" />
Live-Auslastung
</div>
<div className="hm-zahl">
42<span>%</span>
</div>
<div className="hm-balken">
<span style={{ width: "42%" }} />
</div>
<div className="hm-karte__text">Gerade viel Platz an der Wand.</div>
</div>
<div className="hm-karte hm-karte--kurs" data-zone="schnupperkurs">
<div className="hm-marke hm-marke--cyan">Für Einsteiger</div>
<div className="hm-karte__titel">Schnupperkurs</div>
<div className="hm-karte__text">Samstag, 10 Uhr, 90 Minuten. Leihschuhe sind dabei.</div>
</div>
<div className="hm-karte" data-zone="preise">
<div className="hm-karte__titel">Preise</div>
<div className="hm-preise">
<div>
<span>Tageskarte</span>
<b>14 €</b>
</div>
<div>
<span>10er-Karte</span>
<b>120 €</b>
</div>
<div>
<span>Monatskarte</span>
<b>59 €</b>
</div>
</div>
</div>
</div>
</>
);
// ── Handy-Fassung (402 x 874, oben 59 und unten 34 frei) ──────────────────────
const Handy = () => (
<>
<div className="hm-h-kopf">
<div className="hm-logo" data-zone="logo">
<LogoZeichen />
<span className="hm-logo__text">
<span className="hm-logo__klein">Boulderhalle</span>
<span className="hm-logo__gross">Überhang</span>
</span>
</div>
<div className="hm-tel" data-zone="telefon">
<Handset />
<span>0221 4710 142</span>
</div>
</div>
<div className="hm-h-nav">
<span className="hm-nav__punkt" data-zone="nav-routen">
Routen
</span>
<span className="hm-nav__punkt" data-zone="nav-kurse">
Kurse
</span>
<span className="hm-nav__punkt">Preise</span>
<span className="hm-nav__punkt">Zeiten</span>
</div>
<div className="hm-h-held">
<div className="hm-held__vor">
<span className="hm-held__punkt" />
Frisch geschraubt
</div>
<div className="hm-held__titel">
<span>
Heute <em>14</em>
</span>
<span>neue Boulder</span>
</div>
<div className="hm-knopf" data-zone="tageskarte">
<span>Tageskarte kaufen</span>
<Pfeil />
</div>
</div>
<div className="hm-h-live hm-karte hm-karte--live" data-zone="auslastung">
<div className="hm-zahl">
42<span>%</span>
</div>
<div className="hm-h-live__rechts">
<div className="hm-karte__kopf">
<span className="hm-live" />
Live-Auslastung
</div>
<div className="hm-balken">
<span style={{ width: "42%" }} />
</div>
</div>
</div>
<div className="hm-wand" data-zone="routenbild">
<Routenwand b={362} h={150} klein />
</div>
<div className="hm-karte hm-karte--kurs hm-h-kurs" data-zone="schnupperkurs">
<div className="hm-karte__titel">Schnupperkurs</div>
<div className="hm-karte__text">Sa 10 Uhr, Leihschuhe dabei</div>
</div>
<div className="hm-h-reihe">
<div className="hm-karte" data-zone="preise">
<div className="hm-karte__titel">Preise</div>
<div className="hm-preise">
<div>
<span>Tageskarte</span>
<b>14 €</b>
</div>
<div>
<span>Monat</span>
<b>59 €</b>
</div>
</div>
</div>
<div className="hm-karte" data-zone="oeffnungszeiten">
<div className="hm-karte__titel">Geöffnet</div>
<div className="hm-preise">
<div>
<span>Mo–Fr</span>
<b>10–23</b>
</div>
<div>
<span>Sa, So</span>
<b>9–21</b>
</div>
</div>
</div>
</div>
</>
);
export default HeatmapSeite;
export { HeatmapSeite };