Bühne: Live-Vorschau mit echter Bäckerei-Website, Chat als Live-Demo mit Zoom, weiches Zuklappen

- Live-Vorschau: Im MacBook steht die Beispiel-Website der Bäckerei Sonnenschein selbst
  (Startbereich, Sortiment, Backstube, Torten, Öffnungszeiten, Fuß; Instrument Serif,
  CC0-Fotos von Wikimedia Commons, nur Backwaren). Beim Scrollen durch die Station läuft
  sie einmal durch (--kp-v, nur transform). Portal-Aufnahmen (Projekte, Als App) neu mit
  derselben Website im Vorschaukasten.
- Chat: Portal-Chat als Live-Demo. Die Kamera zoomt aufs Gespräch, Foto anhängen, tippen,
  Sprachnachricht, Antworten von Kenso ploppen auf (spielerische Texte, Sie-Form). Das iPhone
  gleitet in dieser Station weg. Hinweis auf die E-Mail bei Team-Antworten (K17).
- „Funktionen“: Unterpunkte falten sich beim Verlassen ein, danach gleitet „Als App“ per FLIP
  nach oben (auch beim Aufklappen nach unten), Pfeil zeigt den Zustand.
- Schrittlängen je Station (normal, Demo, Vorschau, Chat), bei reduzierter Bewegung kurz.
- Tests: Bühne angepasst, neue Tests für Chat-Ablauf und Bäckerei-Website (287 grün).

Spezifikation: docs/superpowers/specs/2026-10-04-vorschau-chat.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-04 18:16:15 +00:00
1 parent 58ac7745e6
commit c1d775e090
47 files changed
+2537 -87

No files matched your search

+182 -41
View File
@@ -1,10 +1,23 @@
import { lazy, Suspense, useEffect, useRef, useState, useSyncExternalStore, version, type ComponentType, type ReactNode, type RefObject } from "react";
import {
lazy,
Suspense,
useEffect,
useLayoutEffect,
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 { useBewegungErlaubt, 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 type { ChatZustand } from "./demos/chat/ablauf";
import "./kundenportal.css";
/**
@@ -15,6 +28,10 @@ import "./kundenportal.css";
* 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.
* Live sind außerdem (Wunsch des Inhabers 2026-10-04, Spezifikation 2026-10-04-vorschau-chat):
* - Live-Vorschau: im MacBook die Beispiel-Website der Bäckerei Sonnenschein, die beim Scrollen durch die Station mitläuft;
* - Chat: im MacBook der Portal-Chat, die Kamera zoomt hinein, Nachrichten ploppen auf (im iPhone im Gleichtakt).
* Der Überpunkt „Funktionen“ klappt weich auf und zu (Unterpunkte falten sich ein, „Als App“ gleitet nach, FLIP).
*/
const EINLEITUNG =
@@ -51,6 +68,9 @@ const handyBild = (name: string, alt: string, sizes: string, hoehe = 1696): Rahm
});
type DemoArt = "termin" | "heatmap" | "pinnwand";
/** Live-Stationen außerhalb der Gruppe „Funktionen“ */
type LiveArt = "vorschau" | "chat";
type LadeArt = DemoArt | LiveArt;
type Funktion = {
id: string;
@@ -64,13 +84,17 @@ type Funktion = {
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. */
/** Live-Demo statt Desktop-Bild: welche erfundene Kunden-Website im MacBook läuft (gehört zur Gruppe „Funktionen“). */
demo?: DemoArt;
/** Live-Station außerhalb der Gruppe: Beispiel-Website (Live-Vorschau) oder Portal-Chat mit Zoom (Chat). */
live?: LiveArt;
/** Text der Zeile „Erfundenes Beispiel · …“ unter dem MacBook, wenn nicht die Adresse dort stehen soll. */
beispiel?: string;
/** 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 unter dem MacBook (Live-Vorschau, Chat, Terminplaner, Pinnwand; bei der Website-Analyse steht dort die Diagnose). */
hinweis?: string;
};
@@ -84,7 +108,12 @@ const FUNKTIONEN: Funktion[] = [
id: "live-vorschau",
titel: "Live-Vorschau",
unterschrift: "Ihre Website, noch während wir daran bauen.",
desktop: { name: "desktop-1-projekte", alt: "Kundenportal: Projektübersicht mit Live-Vorschau der Website" },
live: "vorschau",
adresse: "baeckerei-sonnenschein.project.webklar.com",
beispiel: "Bäckerei Sonnenschein",
beschreibung:
"Beispiel-Website einer Bäckerei in der Live-Vorschau: Startseite mit frischem Roggenbrot, Sortiment, Backstube, Torten und Öffnungszeiten",
hinweis: "Geschützt: Ihre Vorschau öffnet sich nur nach der Anmeldung im Kundenportal – Fremde sehen nichts.",
handy: { name: "phone-1-projekte", alt: "Kundenportal auf dem Handy: Projektübersicht mit Live-Vorschau der Website" },
},
{
@@ -104,8 +133,12 @@ const FUNKTIONEN: Funktion[] = [
id: "chat",
titel: "Chat",
unterschrift: "Frage, Foto oder Sprachnachricht direkt ans Team.",
desktop: { name: "desktop-3b-chat-foto", alt: "Kundenportal: Chat mit dem Team, im Verlauf ein Foto" },
handy: { name: "phone-4b-chat-foto", alt: "Kundenportal auf dem Handy: Chat mit Foto" },
live: "chat",
beispiel: "Bäckerei Sonnenschein im Kundenportal",
beschreibung:
"Chat im Kundenportal: Die Bäckerei schickt ein Foto vom neuen Roggenbrot und eine Sprachnachricht, Kenso vom WEBklar-Team antwortet",
hinweis: "Antwortet das Team, kommt zusätzlich eine E-Mail – so verpassen Sie keine Nachricht.",
handy: { name: "phone-4c-chat-leer", alt: "Kundenportal auf dem Handy: Chat mit dem WEBklar-Team" },
},
{
id: "rechnungen",
@@ -166,9 +199,16 @@ 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;
/** Art der Station: bestimmt, was nachgeladen wird und welche Hinweise unter dem MacBook stehen. */
const ladeArt = (f: Funktion): LadeArt | undefined => f.demo ?? f.live;
/**
* Länge jedes Schritts auf der Bühne (CSS-Variable aus kundenportal.css): normale Stationen kurz, die Demos länger
* (der Ablauf soll zu sehen sein), die Live-Vorschau am längsten, weil die Website beim Scrollen durchläuft.
* Die Summe ergibt mit der Bühnenhöhe und dem letzten Schritt die Scrollstrecke der Hülle.
*/
const strecke = (f: Funktion) => (f.live ? `var(--kp-l-${f.live})` : f.demo ? "var(--kp-l-demo)" : "var(--kp-l-normal)");
const HUELLE_HOEHE = `calc(var(--kp-buehne-h) + ${FUNKTIONEN.slice(0, LETZTER).map(strecke).join(" + ")} + var(--kp-ende))`;
/** Ü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." };
@@ -187,6 +227,10 @@ const lazyMitVorladen = <P,>(laden: () => Promise<{ default: ComponentType<P> }>
return Object.assign(Komponente, { vorladen: () => void lade().catch(() => {}) });
};
const BaeckereiSeite = lazyMitVorladen(() => import("./demos/baeckerei/BaeckereiSeite"));
const ChatSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.ChatSteuerung })));
const ChatSeite = lazyMitVorladen(() => import("./demos/chat/ChatSeite"));
const ChatHandy = lazyMitVorladen(() => import("./demos/chat/ChatHandy"));
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 })));
@@ -198,22 +242,31 @@ const PinnwandSeite = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandSei
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)[]> = {
const VORLADEN: Record<LadeArt, (() => void)[]> = {
vorschau: [BaeckereiSeite.vorladen],
// ChatHandy braucht nur die Wisch-Reihe (auf der Bühne gleitet das iPhone in der Station Chat weg), ist aber winzig
chat: [ChatSteuerung.vorladen, ChatSeite.vorladen, ChatHandy.vorladen],
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 }) => (
const PLATZHALTER: Record<LadeArt, string> = {
vorschau: "#f7f0e4",
chat: "#0b0712",
termin: "#f6f1ea",
heatmap: "#141414",
pinnwand: "#f7f2e6",
};
const Platzhalter = ({ art }: { art: LadeArt }) => (
<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 beispielText = (f: Funktion) => `Erfundenes Beispiel · ${f.beispiel ?? f.adresse}`;
/** Satz unter dem Titel einer Karte der Wisch-Reihe; die drei Funktionen haben keinen (nur der Titel). */
const KartenUnterschrift = ({ f }: { f: Funktion }) =>
@@ -266,6 +319,10 @@ const Buehne = () => {
const lauf = useDemoLauf(true, imBild);
const fein = useFeinerZeiger();
const aktivDemo = FUNKTIONEN[aktiv].demo;
const aktivLive = FUNKTIONEN[aktiv].live;
const aktivArt = ladeArt(FUNKTIONEN[aktiv]);
const bewegung = useBewegungErlaubt();
const [chat, setChat] = useState<ChatZustand | null>(null);
const [termin, setTermin] = useState<TerminZustand | null>(null);
const [heat, setHeat] = useState<HeatmapZustand | null>(null);
const [pinnwand, setPinnwand] = useState<PinnwandZustand | null>(null);
@@ -301,6 +358,7 @@ const Buehne = () => {
raf = 0;
if (!ruhig.matches) {
el.style.setProperty("--kp-q", "0");
el.style.setProperty("--kp-v", "0");
return;
}
const vh = window.innerHeight;
@@ -315,6 +373,15 @@ const Buehne = () => {
const halten = 0.2 * vh; // am Ende bleibt der lange Screenshot kurz stehen
const q = begrenze((seitSchritt6 - vorlauf) / Math.max(1, laenge - vorlauf - halten), 0, 1);
el.style.setProperty("--kp-q", q.toFixed(4));
// Live-Vorschau (erster Schritt): die Beispiel-Website läuft vom Anheften der Bühne bis zum nächsten Schritt
// einmal von oben nach unten durch; am Anfang und am Ende bleibt sie kurz stehen
const ersterMarker = marker.current[0];
if (ersterMarker) {
const laenge1 = ersterMarker.offsetHeight - (vh / 2 - oben);
const v = begrenze((gescrollt - 0.06 * vh) / Math.max(1, laenge1 - 0.2 * vh), 0, 1);
el.style.setProperty("--kp-v", v.toFixed(4));
}
};
const planen = () => {
if (!raf) raf = requestAnimationFrame(berechne);
@@ -348,23 +415,60 @@ 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));
const demoEingehaengt = (art: LadeArt) => FUNKTIONEN.some((f, i) => ladeArt(f) === 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());
for (const art of Object.keys(VORLADEN) as LadeArt[]) {
if (FUNKTIONEN.some((f, i) => ladeArt(f) === art && i <= hoechster + 1)) VORLADEN[art].forEach((vorladen) => vorladen());
}
}, [hoechster]);
// Überpunkt „Funktionen“: offen, solange eine der drei Funktionen aktiv ist. Beim Verlassen falten sich die Unterpunkte
// erst ein („schliesst“), dann verschwindet die Unterliste und „Als App“ gleitet nach oben (ohne Bewegung: sofort zu).
const [gruppe, setGruppe] = useState<"zu" | "offen" | "schliesst">("zu");
const gruppeGewuenscht = aktivDemo !== undefined;
useEffect(() => {
if (gruppeGewuenscht) setGruppe("offen");
else setGruppe((g) => (g === "offen" && bewegung ? "schliesst" : "zu"));
}, [gruppeGewuenscht, bewegung]);
useEffect(() => {
if (gruppe !== "schliesst") return;
const t = window.setTimeout(() => setGruppe("zu"), 260);
return () => window.clearTimeout(t);
}, [gruppe]);
// FLIP: Ändert sich die Höhe der Gruppe, gleiten die Einträge darunter von ihrer alten Lage an die neue (nur transform).
// Die Lagen werden bei jedem Stationswechsel gemerkt, bewegt wird nur, wenn die Gruppe auf- oder zugeht.
const liste = useRef<HTMLOListElement>(null);
const lagen = useRef(new Map<Element, number>());
const letzteGruppe = useRef(gruppe);
useLayoutEffect(() => {
const ol = liste.current;
if (!ol) return;
const neu = new Map<Element, number>();
for (const li of Array.from(ol.children)) neu.set(li, (li as HTMLElement).offsetTop);
if (bewegung && letzteGruppe.current !== gruppe) {
for (const [li, oben] of neu) {
const alt = lagen.current.get(li);
if (alt === undefined || alt === oben || typeof li.animate !== "function") continue;
li.animate([{ transform: `translateY(${alt - oben}px)` }, { transform: "translateY(0)" }], {
duration: 480,
easing: "cubic-bezier(0.2, 0.8, 0.2, 1)",
});
}
}
letzteGruppe.current = gruppe;
lagen.current = neu;
}, [gruppe, aktiv, bewegung]);
return (
<div
className="kp-huelle"
ref={huelle}
style={{ ["--kp-nn" as string]: ANZAHL_NORMAL, ["--kp-nd" as string]: ANZAHL_DEMO }}
>
<div className="kp-huelle" ref={huelle} style={{ height: HUELLE_HOEHE }}>
{/* Taktgeber der Demos (rendern nichts): erst geladen, wenn die Bühne ihre Station fast erreicht */}
<Suspense fallback={null}>
{demoEingehaengt("chat") ? (
<ChatSteuerung laeuft={lauf.laeuft && aktivLive === "chat"} statisch={lauf.statisch} onZustand={setChat} />
) : null}
{demoEingehaengt("termin") ? (
<TerminSteuerung laeuft={lauf.laeuft && aktivDemo === "termin"} statisch={lauf.statisch} onZustand={setTermin} />
) : null}
@@ -385,16 +489,17 @@ const Buehne = () => {
{FUNKTIONEN.map((f, i) => (
<div
key={f.id}
className={`kp-marker${f.demo ? " kp-marker--demo" : ""}`}
className="kp-marker"
data-index={i}
style={{ ["--kp-l" as string]: strecke(f) }}
ref={(el) => (marker.current[i] = el)}
/>
))}
</div>
<div className="kp-buehne" ref={buehne} data-app={app ? "ja" : "nein"}>
<div className="kp-buehne" ref={buehne} data-app={app ? "ja" : "nein"} data-station={FUNKTIONEN[aktiv].id}>
<div className="wk-container kp-buehne__innen">
<ol className="kp-liste" aria-label="Funktionen des Kundenportals">
<ol className="kp-liste" aria-label="Funktionen des Kundenportals" ref={liste}>
{FUNKTIONEN.map((f, i) => {
if (!f.demo) {
return (
@@ -414,9 +519,9 @@ const Buehne = () => {
}
// die drei Funktionen: ein Überpunkt, der aufklappt, sobald eine von ihnen erreicht ist
if (i !== DEMO_INDIZES[0]) return null;
const offen = aktivDemo !== undefined;
const offen = gruppe === "offen";
return (
<li key="funktionen" className="kp-gruppe" data-offen={offen ? "ja" : "nein"}>
<li key="funktionen" className="kp-gruppe" data-offen={offen ? "ja" : "nein"} data-zustand={gruppe}>
<button
type="button"
className="kp-eintrag kp-eintrag--gruppe"
@@ -424,10 +529,21 @@ const Buehne = () => {
aria-controls="kp-unterliste"
onClick={() => springe(DEMO_INDIZES[0])}
>
<span className="kp-eintrag__titel">{GRUPPE.titel}</span>
<span className="kp-eintrag__titel">
{GRUPPE.titel}
<svg className="kp-eintrag__pfeil" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="m4 6 4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className="kp-eintrag__text">{GRUPPE.text}</span>
</button>
<ol id="kp-unterliste" className="kp-unterliste" aria-label={GRUPPE.titel} hidden={!offen}>
<ol
id="kp-unterliste"
className="kp-unterliste"
aria-label={GRUPPE.titel}
hidden={gruppe === "zu"}
aria-hidden={gruppe === "schliesst" || undefined}
>
{DEMO_INDIZES.map((d) => (
<li key={FUNKTIONEN[d].id}>
<button
@@ -451,7 +567,8 @@ const Buehne = () => {
<DesktopFenster className="kp-fenster" adresse={FUNKTIONEN[aktiv].adresse}>
<div className="kp-ebenen" aria-hidden={app || undefined}>
{FUNKTIONEN.map((f, i) => {
if (!eingehaengt(i) || !(f.desktop || f.demo)) return null;
const art = ladeArt(f);
if (!eingehaengt(i) || !(f.desktop || art)) return null;
return (
<div
key={f.id}
@@ -463,9 +580,11 @@ const Buehne = () => {
>
{f.desktop ? (
<RahmenBild bild={desktopBild(f.desktop.name, f.desktop.alt)} />
) : f.demo ? (
) : art ? (
// ohne gemeldeten Zustand steht die Website im Anfangszustand da (Chunk lädt parallel zum Taktgeber)
<Suspense fallback={<Platzhalter art={f.demo} />}>
<Suspense fallback={<Platzhalter art={art} />}>
{f.live === "vorschau" ? <BaeckereiSeite /> : null}
{f.live === "chat" ? <ChatSeite zustand={chat ?? undefined} /> : null}
{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}
@@ -494,6 +613,7 @@ const Buehne = () => {
)}
{f.demo ? (
// Live-Ebene über dem Portal-Bild: Mitteilung bzw. Tipp-Finger, synchron zur Website
// (in der Station Chat gleitet das iPhone weg, dort gibt es keine Ebene)
<Suspense fallback={null}>
{f.demo === "termin" && termin ? <TerminHandy zustand={termin} /> : null}
{f.demo === "pinnwand" && pinnwand ? <PinnwandHandy zustand={pinnwand} punkt={AUFHAENGEN_PUNKT} /> : null}
@@ -504,16 +624,16 @@ const Buehne = () => {
)}
</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) */}
{/* unter dem MacBook, links neben dem iPhone: Hinweis auf das erfundene Beispiel und je Live-Station eine kleine Karte
(Website-Analyse: die Diagnose, sonst ein Hinweis zur Funktion) */}
<div className="kp-unter">
<div className="kp-beispiele">
{FUNKTIONEN.filter((f) => f.demo).map((f) => (
{FUNKTIONEN.filter((f) => ladeArt(f)).map((f) => (
<p
key={f.id}
className="kp-beispiel"
data-an={f.demo === aktivDemo ? "ja" : "nein"}
aria-hidden={f.demo !== aktivDemo || undefined}
data-an={ladeArt(f) === aktivArt ? "ja" : "nein"}
aria-hidden={ladeArt(f) !== aktivArt || undefined}
>
{/* Website-Analyse: statt der Adresse (steht in der Safari-Leiste) der Bedienhinweis, damit es eine Zeile bleibt;
ohne Maus (Tablet quer) heißt es „Tippen Sie“ statt „Zeigen Sie mit der Maus“ */}
@@ -536,12 +656,12 @@ const Buehne = () => {
</Suspense>
) : null}
</div>
{FUNKTIONEN.filter((f) => f.demo && f.hinweis).map((f) => (
{FUNKTIONEN.filter((f) => ladeArt(f) && f.hinweis).map((f) => (
<p
key={f.id}
className="kp-hinweis"
data-an={f.demo === aktivDemo ? "ja" : "nein"}
aria-hidden={f.demo !== aktivDemo || undefined}
data-an={ladeArt(f) === aktivArt ? "ja" : "nein"}
aria-hidden={ladeArt(f) !== aktivArt || undefined}
>
{f.hinweis}
</p>
@@ -588,9 +708,12 @@ const DemoKarteRahmen = ({
f,
geraetRef,
nachher,
kulisse,
children,
}: {
f: Funktion;
/** Steht sofort im Handy (vor dem Nachladen der Demo), z. B. die Portal-Aufnahme unter der Chat-Ebene */
kulisse?: ReactNode;
/** 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) */
@@ -600,6 +723,7 @@ const DemoKarteRahmen = ({
<div className="kp-karte kp-karte--demo" data-demo={f.demo}>
<div className="kp-karte__geraet" ref={geraetRef}>
<HandyRahmen className="kp-karte__handy">
{kulisse}
<Suspense fallback={f.demo ? <Platzhalter art={f.demo} /> : null}>{children}</Suspense>
</HandyRahmen>
</div>
@@ -616,7 +740,7 @@ const DemoKarteRahmen = ({
* 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 useKarte = (art: LadeArt) => {
const ref = useRef<HTMLDivElement>(null);
const nah = useImBild(ref, 0);
const imBild = useImBild(ref, 0.6);
@@ -643,6 +767,22 @@ const TerminKarte = ({ f }: { f: Funktion }) => {
);
};
/** Chat-Karte: die Portal-Aufnahme des leeren Chats, darüber das Gespräch, sobald die Karte in Sicht kommt. */
const ChatKarte = ({ f }: { f: Funktion }) => {
const k = useKarte("chat");
const [z, setZ] = useState<ChatZustand | null>(null);
return (
<DemoKarteRahmen
f={f}
geraetRef={k.ref}
kulisse={<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "250px")} />}
>
{k.gesehen ? <ChatSteuerung laeuft={k.laeuft} statisch={k.statisch} onZustand={setZ} /> : null}
{k.gesehen && z ? <ChatHandy zustand={z} /> : null}
</DemoKarteRahmen>
);
};
const PinnwandKarte = ({ f }: { f: Funktion }) => {
const k = useKarte("pinnwand");
const [z, setZ] = useState<PinnwandZustand | null>(null);
@@ -679,6 +819,7 @@ const HandyReihe = () => (
<WischReihe ariaLabel="Acht Funktionen des Kundenportals, seitlich wischen">
{FUNKTIONEN.map((f) => {
if (f.lang) return <LangeKarte key={f.id} f={f} />;
if (f.live === "chat") return <ChatKarte 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} />;
@@ -0,0 +1,271 @@
import { memo, useId, useLayoutEffect, useRef } from "react";
import { FLAECHE } from "../gemeinsam";
import "./baeckerei.css";
/**
* Die Beispiel-Website der Station „Live-Vorschau“: die erfundene „Bäckerei Sonnenschein“, Demo-Kundin des Kundenportals
* (gleiche Adresse wie in den Portal-Aufnahmen: baeckerei-sonnenschein.project.webklar.com).
* Desktop = 1512 px breit (MacBook-Fläche), die Seite ist länger als der Bildschirm: Beim Scrollen durch die Station
* läuft sie im MacBook nach oben (Fortschritt 0 bis 1 kommt als CSS-Variable --kp-v von der Bühne, nur transform).
* Fotos: Wikimedia Commons, CC0, nur Backwaren (docs/relaunch/assets/baeckerei/QUELLEN.md).
* Die Website ist reine Kulisse: aria-hidden, keine fokussierbaren Elemente (div/span statt a/button).
*/
const FOTOS = "/relaunch/demos/baeckerei";
const foto = (name: string) => `${FOTOS}/${name}.webp`;
/** Logo: kleine Sonne über einem Brotlaib, in der Farbe der Kruste. */
const Logo = () => (
<svg className="bk-logo__zeichen" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
<circle cx="24" cy="24" r="23" fill="#2a1a10" />
<g stroke="#f2b544" strokeWidth="2.6" strokeLinecap="round">
<path d="M24 8v4M13 12.5l2.6 2.8M35 12.5l-2.6 2.8M8.5 22h3.6M39.5 22h-3.6" />
</g>
<path d="M16.5 26a7.5 7.5 0 0 1 15 0" fill="#f2b544" />
<path d="M10.5 33.5c0-4.2 6-6.4 13.5-6.4s13.5 2.2 13.5 6.4c0 1.7-1.4 2.6-3.2 2.6H13.7c-1.8 0-3.2-.9-3.2-2.6z" fill="#e8d3b2" />
<path d="M17 30.5l2.4 2.6M23 29.8l2.4 2.6M29 30.5l2.4 2.6" stroke="#2a1a10" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
/** Runder Stempel „Handwerk · seit 1987 · Altstadt ·“ (Schrift auf einer Kreisbahn, auf den Umfang gestreckt). */
const Stempel = () => {
const id = `${useId().replace(/:/g, "")}-kreis`;
return (
<svg className="bk-stempel" viewBox="0 0 120 120" aria-hidden="true" focusable="false">
<defs>
<path id={id} d="M60 60m-44 0a44 44 0 1 1 88 0a44 44 0 1 1-88 0" />
</defs>
<circle cx="60" cy="60" r="58" fill="#f2b544" />
<text fontSize="10.5" fontWeight="600" fill="#2a1a10">
<textPath href={`#${id}`} textLength="272" lengthAdjust="spacing">
HANDWERK · SEIT 1987 · ALTSTADT ·
</textPath>
</text>
<path d="M44 66c0-6 7.2-9.2 16-9.2s16 3.2 16 9.2c0 2.2-1.8 3.4-4 3.4H48c-2.2 0-4-1.2-4-3.4z" fill="#2a1a10" />
<path d="M52 62l2.6 2.8M59 61l2.6 2.8M66 62l2.6 2.8" stroke="#f2b544" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
};
const Pfeil = () => (
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M3 8h9.5M8.5 4l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const SORTIMENT = [
{ name: "Roggenbrot", text: "36 Stunden Sauerteig, kräftig und lange saftig.", preis: "4,90 €", bild: "roggenbrot-anschnitt-400", neu: true },
{ name: "Kaiserbrötchen", text: "Außen knusprig, innen fluffig – ab 6 Uhr.", preis: "0,55 €", bild: "broetchen-400" },
{ name: "Buttercroissant", text: "Mit echter Butter, Schicht für Schicht gefaltet.", preis: "1,80 €", bild: "croissants-400" },
{ name: "Zimtschnecke", text: "Mit Hagelzucker. Samstags oft noch warm.", preis: "2,40 €", bild: "zimtschnecken-400" },
];
const ZEITEN = [
{ tage: "Montag – Freitag", zeit: "6:00 – 18:00 Uhr", heute: true },
{ tage: "Samstag", zeit: "6:00 – 13:00 Uhr" },
{ tage: "Sonntag", zeit: "7:00 – 11:00 Uhr" },
];
const BANDWOERTER = ["Roggenbrot", "Dinkelvollkorn", "Buttercroissant", "Laugenbrezel", "Zimtschnecke", "Zwetschgenkuchen", "Kaiserbrötchen"];
const BaeckereiSeite = () => {
const seite = useRef<HTMLDivElement>(null);
const inhalt = useRef<HTMLDivElement>(null);
// Wie weit die Seite im MacBook nach oben laufen kann (Designpunkte, unabhängig vom Maßstab des Rahmens)
useLayoutEffect(() => {
const s = seite.current;
const i = inhalt.current;
if (!s || !i) return;
const messen = () => s.style.setProperty("--bk-weg", `${Math.max(0, i.offsetHeight - FLAECHE.desktop.hoehe)}px`);
messen();
if (typeof ResizeObserver === "undefined") return;
const beobachter = new ResizeObserver(messen);
beobachter.observe(i);
return () => beobachter.disconnect();
}, []);
return (
<div className="bk-seite" ref={seite} aria-hidden="true">
<div className="bk-inhalt" ref={inhalt}>
<div className="bk-leiste">
<span>
<b>Heute frisch aus dem Ofen:</b> Roggenbrot mit 36 Stunden Sauerteig
</span>
<span className="bk-leiste__rechts">Für morgen vorbestellen bis 18 Uhr</span>
</div>
<div className="bk-kopf">
<div className="bk-logo">
<Logo />
<span className="bk-logo__text">
<span className="bk-logo__name">Sonnenschein</span>
<span className="bk-logo__zusatz">Bäckerei · seit 1987</span>
</span>
</div>
<div className="bk-menue">
<span>Sortiment</span>
<span>Backstube</span>
<span>Café</span>
<span>Torten</span>
<span>Kontakt</span>
</div>
<span className="bk-knopf bk-knopf--dunkel bk-knopf--klein">Vorbestellen</span>
</div>
<div className="bk-held">
<div className="bk-held__text">
<span className="bk-offen">
<i />
Heute geöffnet · 6 – 18 Uhr
</span>
<h1 className="bk-held__titel">
Frisch gebacken.
<br />
<em>Jeden Morgen.</em>
</h1>
<p className="bk-held__lead">
Brot, Brötchen und Kuchen aus unserer Backstube in der Altstadt – mit Sauerteig, viel Zeit und Mehl aus der
Region.
</p>
<div className="bk-held__knoepfe">
<span className="bk-knopf bk-knopf--dunkel">
Sortiment ansehen <Pfeil />
</span>
<span className="bk-knopf bk-knopf--rand">Torte bestellen</span>
</div>
<div className="bk-fakten">
<span>
<b>36 Std.</b>Teigruhe
</span>
<span>
<b>4:00</b>Ofen an
</span>
<span>
<b>1987</b>in der Altstadt
</span>
</div>
</div>
<div className="bk-held__bild">
<img src={foto("roggenbrot-600")} width={600} height={556} alt="" decoding="async" />
<div className="bk-neu">
<span className="bk-neu__marke">Neu im Regal</span>
<span className="bk-neu__name">Roggenbrot</span>
<span className="bk-neu__zeile">
36 Std. Sauerteig · 750 g <b>4,90 €</b>
</span>
</div>
<Stempel />
</div>
</div>
<div className="bk-band">
{BANDWOERTER.map((w) => (
<span key={w}>{w}</span>
))}
</div>
<div className="bk-abschnitt">
<div className="bk-abschnitt__kopf">
<div>
<span className="bk-dach">Unser Sortiment</span>
<h2 className="bk-h2">Aus dem Ofen, direkt in die Tüte.</h2>
</div>
<span className="bk-link">
Ganzes Sortiment <Pfeil />
</span>
</div>
<div className="bk-karten">
{SORTIMENT.map((s) => (
<div key={s.name} className="bk-karte">
<div className="bk-karte__bild">
<img src={foto(s.bild)} width={400} height={500} alt="" loading="lazy" decoding="async" />
{s.neu ? <span className="bk-karte__neu">Neu</span> : null}
</div>
<div className="bk-karte__zeile">
<span className="bk-karte__name">{s.name}</span>
<span className="bk-karte__preis">{s.preis}</span>
</div>
<p className="bk-karte__text">{s.text}</p>
</div>
))}
</div>
</div>
<div className="bk-backstube">
<div className="bk-backstube__bild">
<img src={foto("baguettes-700")} width={700} height={583} alt="" loading="lazy" decoding="async" />
</div>
<div className="bk-backstube__text">
<span className="bk-dach">Unsere Backstube</span>
<h2 className="bk-h2">Bei uns hat der Teig Zeit.</h2>
<p>
Um vier Uhr geht der Ofen an, aber angefangen hat alles schon am Vortag: Unser Sauerteig ruht bis zu 36 Stunden,
bevor er zu Brot wird. Das schmeckt man – und das Brot bleibt tagelang frisch.
</p>
<p className="bk-unterschrift">Marlene Sonnenschein, Bäckermeisterin</p>
</div>
</div>
<div className="bk-torten">
<div className="bk-torten__text">
<span className="bk-dach bk-dach--hell">Kuchen &amp; Torten</span>
<h2 className="bk-h2 bk-h2--hell">Zum Geburtstag, zur Hochzeit oder einfach so.</h2>
<p>Zwei Tage vorher bestellen, frisch bei uns abholen.</p>
<span className="bk-knopf bk-knopf--sonne">
Torte anfragen <Pfeil />
</span>
</div>
<img src={foto("zwetschgenkuchen-520")} width={520} height={520} alt="" loading="lazy" decoding="async" />
</div>
<div className="bk-besuch">
<div className="bk-zeiten">
<span className="bk-dach">Öffnungszeiten</span>
{ZEITEN.map((z) => (
<div key={z.tage} className={`bk-zeiten__zeile${z.heute ? " is-heute" : ""}`}>
<span>{z.tage}</span>
<span>{z.zeit}</span>
</div>
))}
</div>
<div className="bk-karte-ort">
<div className="bk-stadtplan">
<span className="bk-strasse bk-strasse--a" />
<span className="bk-strasse bk-strasse--b" />
<span className="bk-strasse bk-strasse--c" />
<span className="bk-park" />
<span className="bk-nadel">
<Logo />
</span>
</div>
<div className="bk-adresse">
<span className="bk-dach">So finden Sie uns</span>
<b>Marktgasse 3, Altstadt</b>
<span>Telefon 01234 / 56 78 90</span>
</div>
</div>
</div>
<div className="bk-fuss">
<div className="bk-logo bk-logo--hell">
<Logo />
<span className="bk-logo__text">
<span className="bk-logo__name">Sonnenschein</span>
<span className="bk-logo__zusatz">Bäckerei · seit 1987</span>
</span>
</div>
<span className="bk-fuss__links">
<span>Sortiment</span>
<span>Öffnungszeiten</span>
<span>Impressum</span>
<span>Datenschutz</span>
</span>
</div>
</div>
</div>
);
};
// memo: die Bühne rendert während der Chat-Demo oft neu, die Website hat keine Props und bleibt stehen
export default memo(BaeckereiSeite);
@@ -0,0 +1,594 @@
/*
* Beispiel-Website „Bäckerei Sonnenschein“ (Station Live-Vorschau). Alle Klassen mit Präfix bk-.
* Desktop 1512 px breit, die Seite ist länger als die MacBook-Fläche (893 px) und läuft beim Scrollen durch die Station
* nach oben: --kp-v (0 bis 1) setzt die Bühne, --bk-weg die Seite selbst. Nur transform, nur ohne reduzierte Bewegung.
*/
/* Instrument Serif, selbst gehostet (SIL Open Font License, public/fonts/instrument-serif/LICENSE) */
@font-face {
font-family: "Instrument Serif";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/instrument-serif/instrument-serif-latin-ext-400-normal.woff2) format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF,
U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Instrument Serif";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/instrument-serif/instrument-serif-latin-400-normal.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
.bk-seite {
--bk-tinte: #2a1a10;
--bk-tinte-leise: #6f5a48;
--bk-creme: #f7f0e4;
--bk-papier: #fffaf2;
--bk-sand: #efe3cf;
--bk-kruste: #a8561e;
--bk-sonne: #f2b544;
--bk-linie: #e6d7bf;
--bk-serif: "Instrument Serif", Georgia, "Times New Roman", serif;
position: absolute;
inset: 0;
overflow: hidden;
background: var(--bk-creme);
color: var(--bk-tinte);
font-family: "Instrument Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-weight: 400;
line-height: 1.45;
text-align: left;
letter-spacing: normal;
-webkit-font-smoothing: antialiased;
user-select: none;
}
.bk-seite *,
.bk-seite *::before,
.bk-seite *::after {
box-sizing: border-box;
margin: 0;
}
.bk-seite img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Die ganze Seite läuft mit dem Scrollfortschritt der Station nach oben */
.bk-inhalt {
position: absolute;
left: 0;
top: 0;
width: 1512px;
}
@media (prefers-reduced-motion: no-preference) {
.bk-inhalt {
transform: translateY(calc(var(--kp-v, 0) * -1 * var(--bk-weg, 0px)));
}
}
/* ── Leiste und Kopf ─────────────────────────────────────────────────── */
.bk-leiste {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: var(--bk-tinte);
color: #f3e6d0;
font-size: 14px;
}
.bk-leiste b {
font-weight: 600;
color: var(--bk-sonne);
}
.bk-leiste__rechts {
position: absolute;
right: 88px;
opacity: 0.75;
}
.bk-kopf {
height: 96px;
display: flex;
align-items: center;
padding: 0 88px;
border-bottom: 1px solid var(--bk-linie);
}
.bk-logo {
display: flex;
align-items: center;
gap: 14px;
}
.bk-logo__zeichen {
width: 48px;
height: 48px;
flex: none;
}
.bk-logo__text {
display: flex;
flex-direction: column;
line-height: 1;
}
.bk-logo__name {
font-family: var(--bk-serif);
font-size: 34px;
letter-spacing: -0.01em;
}
.bk-logo__zusatz {
margin-top: 4px;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--bk-kruste);
}
.bk-menue {
display: flex;
gap: 40px;
margin: 0 auto;
font-size: 16.5px;
font-weight: 500;
color: #4a3626;
}
.bk-knopf {
display: inline-flex;
align-items: center;
gap: 10px;
height: 58px;
padding: 0 30px;
border-radius: 999px;
font-size: 17px;
font-weight: 600;
white-space: nowrap;
}
.bk-knopf svg {
width: 17px;
height: 17px;
}
.bk-knopf--klein {
height: 46px;
padding: 0 24px;
font-size: 15.5px;
}
.bk-knopf--dunkel {
background: var(--bk-tinte);
color: var(--bk-papier);
}
.bk-knopf--rand {
border: 1.5px solid #cdb595;
color: var(--bk-tinte);
}
.bk-knopf--sonne {
background: var(--bk-sonne);
color: var(--bk-tinte);
}
/* ── Startbereich ────────────────────────────────────────────────────── */
.bk-held {
display: grid;
grid-template-columns: 1fr 620px;
gap: 72px;
align-items: center;
height: 640px;
padding: 0 88px;
}
.bk-offen {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 16px 8px 13px;
border-radius: 999px;
background: var(--bk-papier);
border: 1px solid var(--bk-linie);
font-size: 14.5px;
font-weight: 600;
color: #3f5e2c;
}
.bk-offen i {
width: 9px;
height: 9px;
border-radius: 50%;
background: #4c9a3c;
box-shadow: 0 0 0 4px #dcebd3;
}
.bk-held__titel {
margin: 26px 0 22px;
font-family: var(--bk-serif);
font-size: 112px;
font-weight: 400;
line-height: 0.94;
letter-spacing: -0.025em;
}
.bk-held__titel em {
font-style: normal;
color: var(--bk-kruste);
}
.bk-held__lead {
max-width: 540px;
font-size: 20px;
line-height: 1.55;
color: var(--bk-tinte-leise);
}
.bk-held__knoepfe {
display: flex;
gap: 14px;
margin-top: 34px;
}
.bk-fakten {
display: flex;
gap: 40px;
margin-top: 40px;
padding-top: 26px;
border-top: 1px solid var(--bk-linie);
font-size: 14.5px;
color: var(--bk-tinte-leise);
}
.bk-fakten span {
display: flex;
flex-direction: column;
}
.bk-fakten b {
font-family: var(--bk-serif);
font-size: 34px;
font-weight: 400;
line-height: 1.05;
color: var(--bk-tinte);
}
.bk-held__bild {
position: relative;
height: 560px;
}
.bk-held__bild > img {
border-radius: 32px;
box-shadow: 0 40px 70px -40px rgb(70 35 10 / 0.55);
}
.bk-neu {
position: absolute;
left: -44px;
bottom: 40px;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 270px;
padding: 18px 22px 20px;
border-radius: 22px;
background: var(--bk-papier);
box-shadow: 0 24px 50px -24px rgb(60 30 5 / 0.45);
}
.bk-neu__marke {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--bk-kruste);
}
.bk-neu__name {
font-family: var(--bk-serif);
font-size: 32px;
line-height: 1.1;
}
.bk-neu__zeile {
display: flex;
justify-content: space-between;
gap: 20px;
font-size: 14.5px;
color: var(--bk-tinte-leise);
}
.bk-neu__zeile b {
color: var(--bk-tinte);
}
.bk-stempel {
position: absolute;
right: -34px;
top: -34px;
width: 132px;
height: 132px;
transform: rotate(-12deg);
}
/* ── Band ────────────────────────────────────────────────────────────── */
.bk-band {
display: flex;
align-items: center;
justify-content: center;
gap: 34px;
height: 66px;
margin-top: 24px;
background: var(--bk-kruste);
color: #fbeedc;
font-family: var(--bk-serif);
font-size: 27px;
white-space: nowrap;
overflow: hidden;
}
.bk-band span + span::before {
content: "✺";
margin-right: 34px;
font-family: "Instrument Sans", system-ui, sans-serif;
font-size: 16px;
color: var(--bk-sonne);
vertical-align: 4px;
}
/* ── Sortiment ───────────────────────────────────────────────────────── */
.bk-abschnitt {
padding: 88px 88px 96px;
}
.bk-abschnitt__kopf {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 44px;
}
.bk-dach {
display: block;
margin-bottom: 14px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--bk-kruste);
}
.bk-dach--hell {
color: var(--bk-sonne);
}
.bk-h2 {
max-width: 760px;
font-family: var(--bk-serif);
font-size: 66px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.02em;
}
.bk-h2--hell {
color: #fbeedc;
}
.bk-link {
display: inline-flex;
align-items: center;
gap: 8px;
padding-bottom: 4px;
border-bottom: 1.5px solid currentColor;
font-size: 16.5px;
font-weight: 600;
}
.bk-link svg {
width: 16px;
height: 16px;
}
.bk-karten {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 28px;
}
.bk-karte__bild {
position: relative;
aspect-ratio: 4 / 5;
border-radius: 22px;
overflow: hidden;
}
.bk-karte__neu {
position: absolute;
left: 16px;
top: 16px;
padding: 6px 13px;
border-radius: 999px;
background: var(--bk-sonne);
font-size: 13px;
font-weight: 700;
}
.bk-karte__zeile {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-top: 18px;
}
.bk-karte__name {
font-family: var(--bk-serif);
font-size: 30px;
line-height: 1.1;
}
.bk-karte__preis {
font-size: 17px;
font-weight: 600;
}
.bk-karte__text {
margin-top: 6px;
font-size: 15.5px;
color: var(--bk-tinte-leise);
}
/* ── Backstube ───────────────────────────────────────────────────────── */
.bk-backstube {
display: grid;
grid-template-columns: 640px 1fr;
gap: 88px;
align-items: center;
padding: 80px 88px;
background: var(--bk-sand);
}
.bk-backstube__bild {
height: 440px;
border-radius: 28px;
overflow: hidden;
}
.bk-backstube__text p {
max-width: 560px;
margin-top: 26px;
font-size: 19px;
line-height: 1.6;
color: var(--bk-tinte-leise);
}
.bk-backstube__text .bk-unterschrift {
margin-top: 30px;
font-family: var(--bk-serif);
font-size: 26px;
color: var(--bk-tinte);
}
/* ── Torten ──────────────────────────────────────────────────────────── */
.bk-torten {
display: grid;
grid-template-columns: 1fr 380px;
align-items: center;
gap: 64px;
margin: 88px 88px 0;
padding: 0 0 0 72px;
height: 360px;
border-radius: 32px;
background: var(--bk-tinte);
color: #e7d6bd;
overflow: hidden;
}
.bk-torten img {
height: 360px;
}
.bk-torten p {
margin: 20px 0 30px;
font-size: 19px;
}
/* ── Besuch ──────────────────────────────────────────────────────────── */
.bk-besuch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 28px;
padding: 88px;
}
.bk-zeiten,
.bk-karte-ort {
border-radius: 28px;
background: var(--bk-papier);
border: 1px solid var(--bk-linie);
}
.bk-zeiten {
padding: 36px 44px 28px;
}
.bk-zeiten__zeile {
display: flex;
justify-content: space-between;
padding: 18px 0;
border-top: 1px solid var(--bk-linie);
font-size: 19px;
}
.bk-zeiten__zeile.is-heute {
font-weight: 600;
}
.bk-zeiten__zeile.is-heute span:first-child::after {
content: "Heute";
margin-left: 12px;
padding: 3px 10px;
border-radius: 999px;
background: #dcebd3;
color: #3f5e2c;
font-size: 13px;
vertical-align: 2px;
}
.bk-karte-ort {
display: grid;
grid-template-rows: 1fr auto;
overflow: hidden;
}
.bk-stadtplan {
position: relative;
height: 180px;
background: #ece2d0;
overflow: hidden;
}
.bk-strasse {
position: absolute;
background: var(--bk-papier);
}
.bk-strasse--a {
left: -20px;
right: -20px;
top: 92px;
height: 22px;
transform: rotate(-6deg);
}
.bk-strasse--b {
top: -20px;
bottom: -20px;
left: 300px;
width: 18px;
transform: rotate(14deg);
}
.bk-strasse--c {
top: -20px;
bottom: -20px;
left: 520px;
width: 12px;
transform: rotate(-24deg);
}
.bk-park {
position: absolute;
left: 60px;
top: 128px;
width: 170px;
height: 120px;
border-radius: 40px;
background: #cfdcb8;
}
.bk-nadel {
position: absolute;
left: 290px;
top: 52px;
width: 54px;
height: 54px;
border-radius: 50%;
border: 4px solid var(--bk-papier);
box-shadow: 0 10px 20px -8px rgb(60 30 5 / 0.5);
}
.bk-nadel .bk-logo__zeichen {
width: 100%;
height: 100%;
}
.bk-adresse {
display: flex;
flex-direction: column;
gap: 4px;
padding: 28px 40px 32px;
font-size: 17px;
color: var(--bk-tinte-leise);
}
.bk-adresse .bk-dach {
margin-bottom: 8px;
}
.bk-adresse b {
font-family: var(--bk-serif);
font-size: 30px;
font-weight: 400;
color: var(--bk-tinte);
}
/* ── Fuß ─────────────────────────────────────────────────────────────── */
.bk-fuss {
display: flex;
align-items: center;
justify-content: space-between;
height: 150px;
padding: 0 88px;
background: var(--bk-tinte);
color: #f3e6d0;
}
.bk-fuss__links {
display: flex;
gap: 32px;
font-size: 15.5px;
opacity: 0.8;
}
@@ -0,0 +1,197 @@
import { useLayoutEffect, useRef } from "react";
import { DemoZeiger, type DemoVariante } from "../gemeinsam";
import { ABSENDER, NACHRICHTEN, type ChatZiel, type ChatZustand } from "./ablauf";
import "./chat.css";
/**
* Live-Ebene über der Aufnahme des leeren Portal-Chats (desktop-3c-chat-leer bzw. phone-4c-chat-leer, Fixture-Daten):
* die Nachrichten, das Eingabefeld mit getipptem Text, Anhang, Sprachaufnahme, der Senden-Knopf und ein Zeiger.
* Alle Maße in Designpunkten der Fläche, gemessen in der Aufnahme (vorschau-chat/aufnahmen/chat-masse.json).
* Die Blasen bauen den Portal-Chat nach (kundenbereich.css / chat-composer.css: Lila für eigene, Grau fürs Team).
*/
type Kasten = { x: number; y: number; b: number; h: number };
const MASSE: Record<
DemoVariante,
{
liste: Kasten;
eingabe: Kasten & { radius: number; innen: number };
anhang: Kasten;
mikro: Kasten;
senden: Kasten;
}
> = {
desktop: {
liste: { x: 485, y: 248.5, b: 814, h: 410 },
eingabe: { x: 535, y: 676.5, b: 606.5, h: 50, radius: 14, innen: 14 },
anhang: { x: 481, y: 680.5, b: 46, h: 46 },
mikro: { x: 1149.5, y: 680.5, b: 46, h: 46 },
senden: { x: 1203.5, y: 680.5, b: 99.5, h: 46 },
},
handy: {
liste: { x: 29, y: 236, b: 344, h: 455 },
eingabe: { x: 75, y: 707, b: 204, h: 46, radius: 23, innen: 16 },
anhang: { x: 27, y: 711, b: 42, h: 42 },
mikro: { x: 285, y: 711, b: 42, h: 42 },
senden: { x: 333, y: 711, b: 42, h: 42 },
},
};
const FOTO = "/relaunch/demos/baeckerei/roggenbrot-600.webp";
const px = (k: Kasten) => ({ left: k.x, top: k.y, width: k.b, height: k.h });
const mitte = (k: Kasten) => ({ x: k.x + k.b / 2, y: k.y + k.h / 2 });
/** Pegel der laufenden Aufnahme (feste Höhen, sieht bei jedem Laden gleich aus) */
const WELLE = [0.35, 0.6, 0.9, 0.55, 0.75, 1, 0.65, 0.4, 0.8, 0.95, 0.5, 0.7, 0.45, 0.85, 0.6, 0.3, 0.55, 0.75, 0.4, 0.25];
const Blase = ({ i }: { i: number }) => {
const n = NACHRICHTEN[i];
return (
<div className={`ch-blase ch-blase--${n.von}${n.foto ? " ch-blase--foto" : ""}`}>
{n.foto ? <img className="ch-foto" src={FOTO} width={600} height={556} alt="" decoding="async" /> : null}
{n.sprache ? (
// Player wie im Portal (chat-composer.js): Abspielknopf, „Sprachnachricht“, Fortschrittsleiste, Dauer
<span className="ch-audio">
<span className="ch-audio__play">
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M5 3.2v9.6L12.6 8z" fill="currentColor" />
</svg>
</span>
<span className="ch-audio__mitte">
<span className="ch-audio__wort">Sprachnachricht</span>
<span className="ch-audio__leiste" />
</span>
<span className="ch-audio__zeit">{n.sprache}</span>
</span>
) : null}
{n.text ? <span className="ch-text">{n.text}</span> : null}
<span className="ch-meta">
{ABSENDER[n.von]} · {n.zeit}
</span>
</div>
);
};
/**
* Die sichtbaren Nachrichten, unten verankert wie in einem Messenger. Kommt eine neue dazu, rutschen die älteren
* weich nach oben (FLIP mit der Web Animations API, nur transform, nur mit Bewegung).
*/
const Verlauf = ({ zustand }: { zustand: ChatZustand }) => {
const liste = useRef<HTMLDivElement>(null);
const vorher = useRef(zustand.sichtbar);
useLayoutEffect(() => {
const el = liste.current;
const neu = zustand.sichtbar > vorher.current;
vorher.current = zustand.sichtbar;
if (!el || !neu || zustand.statisch || typeof el.animate !== "function") return;
const letzte = el.lastElementChild as HTMLElement | null;
if (!letzte) return;
const abstand = parseFloat(getComputedStyle(el).rowGap) || 0;
const weg = letzte.offsetHeight + abstand;
el.animate([{ transform: `translateY(${weg}px)` }, { transform: "translateY(0)" }], {
duration: 480,
easing: "cubic-bezier(0.2, 0.8, 0.2, 1)",
});
}, [zustand.sichtbar, zustand.statisch]);
return (
<div className="ch-liste" ref={liste} data-aus={zustand.aus ? "ja" : "nein"}>
{NACHRICHTEN.slice(0, zustand.sichtbar).map((_, i) => (
<Blase key={`${zustand.runde}-${i}`} i={i} />
))}
</div>
);
};
const Eingabe = ({ zustand, variante }: { zustand: ChatZustand; variante: DemoVariante }) => {
const m = MASSE[variante];
const tippt = zustand.eingabe.length > 0;
return (
<>
{zustand.anhang ? (
<span className="ch-anhang" style={{ left: m.eingabe.x, top: m.eingabe.y - 74 }}>
<img src={FOTO} width={600} height={556} alt="" decoding="async" />
</span>
) : null}
{tippt ? (
<span
className="ch-feld"
style={{ ...px(m.eingabe), borderRadius: m.eingabe.radius, paddingInline: m.eingabe.innen }}
>
<span className="ch-feld__text">
{zustand.eingabe}
<span className="ch-feld__strich" />
</span>
</span>
) : null}
{/* Senden: aktiv, sobald Text im Feld steht; beim Klick drückt er sich kurz ein */}
{tippt || zustand.zeiger === "senden" ? (
<span key={`s-${zustand.klick}`} className="ch-senden" data-druck={zustand.zeiger === "senden" && zustand.klick ? "ja" : "nein"} style={px(m.senden)}>
{variante === "desktop" ? (
"Senden"
) : (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M21 3 10 14M21 3l-7 18-4-7-7-4z" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
</svg>
)}
</span>
) : null}
{/* Sprachaufnahme: ersetzt wie im Portal Anhang, Textfeld und Mikrofon (rote Leiste mit Zeit und Pegel) */}
{zustand.aufnahme !== null ? (
<span
className="ch-aufnahme"
// so hoch wie das Eingabefeld (die Knöpfe sind etwas niedriger), damit von der Aufnahme darunter nichts durchscheint
style={{ left: m.anhang.x, top: m.eingabe.y, width: m.mikro.x + m.mikro.b - m.anhang.x, height: m.eingabe.h }}
>
<span className="ch-aufnahme__punkt" />
{variante === "desktop" ? <span className="ch-aufnahme__wort">Aufnahme</span> : null}
<span className="ch-aufnahme__zeit">0:{String(zustand.aufnahme).padStart(2, "0")}</span>
<span className="ch-aufnahme__pegel">
{WELLE.map((h, k) => (
<i key={k} style={{ ["--ch-h" as string]: h, animationDelay: `${(k % 5) * -0.13}s` }} />
))}
</span>
<span className="ch-aufnahme__stopp" />
</span>
) : null}
</>
);
};
/** Wo der Zeiger hinzeigt; in Ruhe wartet er unsichtbar unten rechts neben dem Eingabefeld. */
const zeigerZiel = (ziel: ChatZiel, variante: DemoVariante) => {
const m = MASSE[variante];
if (ziel === "anhang") return mitte(m.anhang);
if (ziel === "mikro") return mitte(m.mikro);
if (ziel === "senden") return mitte(m.senden);
return { x: m.senden.x + m.senden.b / 2, y: m.senden.y + 90 };
};
const ChatEbene = ({ zustand, variante }: { zustand: ChatZustand; variante: DemoVariante }) => {
const ziel = zeigerZiel(zustand.zeiger, variante);
const m = MASSE[variante];
return (
<div className="ch-ebene" data-variante={variante}>
<div className="ch-ausschnitt" style={px(m.liste)}>
<Verlauf zustand={zustand} />
</div>
<Eingabe zustand={zustand} variante={variante} />
<DemoZeiger
x={ziel.x}
y={ziel.y}
art={variante === "desktop" ? "maus" : "finger"}
klick={zustand.klick}
sichtbar={zustand.zeiger !== "ruhe"}
groesse={variante === "desktop" ? 1.1 : 1.2}
/>
</div>
);
};
export default ChatEbene;
@@ -0,0 +1,14 @@
import type { ChatZustand } from "./ablauf";
import ChatEbene from "./ChatEbene";
/**
* Chat-Demo im iPhone (402 x 874): Live-Ebene über der Aufnahme des leeren Portal-Chats auf dem Handy, im Gleichtakt
* mit dem MacBook (derselbe Zustand). Transparent, ohne Zeigerereignisse.
*/
const ChatHandy = ({ zustand }: { zustand: ChatZustand }) => (
<div className="ch-handy" aria-hidden="true">
<ChatEbene zustand={zustand} variante="handy" />
</div>
);
export default ChatHandy;
@@ -0,0 +1,32 @@
import type { ChatZustand } from "./ablauf";
import { CHAT_START } from "./ablauf";
import ChatEbene from "./ChatEbene";
/**
* Chat-Demo im MacBook der Bühne (1512 x 893): die Aufnahme des leeren Portal-Chats (Fixture-Daten) mit der Live-Ebene darüber.
* Die „Kamera“ zoomt auf das Gespräch (nur transform), damit man die Nachrichten lesen kann, und am Ende wieder heraus.
* Das Bild gibt es in 2400 px Breite, damit es auch hineingezoomt scharf bleibt.
*/
const BILD = "/relaunch/portal/desktop-3c-chat-leer";
const ChatSeite = ({ zustand = CHAT_START }: { zustand?: ChatZustand }) => (
<div className="ch-seite" aria-hidden="true">
<div className="ch-kamera" data-zoom={zustand.zoom ? "ja" : "nein"}>
<img
className="ch-kulisse"
src={`${BILD}-1600.webp`}
srcSet={`${BILD}-1600.webp 1600w, ${BILD}-2400.webp 2400w`}
// hineingezoomt ist das Bild etwa 1,7-mal so breit wie das MacBook
sizes="(min-width: 1200px) 1300px, 110vw"
width={1600}
height={945}
alt=""
decoding="async"
/>
<ChatEbene zustand={zustand} variante="desktop" />
</div>
</div>
);
export default ChatSeite;
+178
View File
@@ -0,0 +1,178 @@
import { useEffect, useMemo, useState } from "react";
import { useAblauf } from "../gemeinsam";
/**
* Ablauf der Chat-Demo (Station „Chat“, Wunsch des Inhabers 2026-10-04): Im MacBook ist der Chat des Kundenportals offen,
* die Kamera zoomt auf das Gespräch, damit man mitlesen kann. Marlene von der Bäckerei Sonnenschein hängt ein Foto an,
* tippt, schickt eine Sprachnachricht; Kenso vom WEBklar-Team antwortet. Am Ende zoomt die Kamera wieder heraus.
* Reine Logik, kein CSS: die Komponenten werden per React.lazy nachgeladen.
* Ohne Bewegung (prefers-reduced-motion) steht sofort der Endzustand da: alle Nachrichten, hineingezoomt.
*/
export type ChatNachricht = {
von: "ich" | "team";
text?: string;
/** Foto im Anhang (dasselbe Roggenbrot wie auf der Website der Station Live-Vorschau) */
foto?: boolean;
/** Sprachnachricht mit Dauer */
sprache?: string;
zeit: string;
};
/** Das Gespräch; die Uhrzeiten zeigen, dass Antworten ihre Zeit haben dürfen (Antwortzeit laut Website: spätestens am nächsten Werktag). */
export const NACHRICHTEN: readonly ChatNachricht[] = [
{ von: "ich", foto: true, text: "Guten Morgen! ☀️ Unser neues Roggenbrot ist gerade aus dem Ofen. Darf das auf die Startseite?", zeit: "07:52" },
{ von: "team", text: "Und wie das darf! 😍 Das Foto ist so knusprig, ich rieche es bis hierher.", zeit: "08:41" },
{ von: "ich", sprache: "0:12", zeit: "08:44" },
{
von: "team",
text: "Alles notiert: Brot ganz nach oben, Preis dazu, Öffnungszeiten etwas größer. Schauen Sie mal in die Vorschau! 🥖",
zeit: "10:20",
},
{ von: "ich", text: "Sieht super aus! Morgen gibt’s Croissants fürs ganze Team. 🥐", zeit: "10:26" },
{ von: "team", text: "Dann stellen wir schon mal den Kaffee an. ☕", zeit: "10:31" },
];
export const ABSENDER = { ich: "Du", team: "Kenso" } as const;
/** Wohin der Zeiger zeigt (Knöpfe des Eingabebereichs) */
export type ChatZiel = "ruhe" | "anhang" | "senden" | "mikro";
export type ChatZustand = {
/** Anzahl der sichtbaren Nachrichten (0 bis 6) */
sichtbar: number;
/** Text im Eingabefeld (wird Zeichen für Zeichen getippt) */
eingabe: string;
/** Foto liegt als Anhang über dem Eingabefeld */
anhang: boolean;
/** Sprachaufnahme läuft: Sekunden, sonst null */
aufnahme: number | null;
/** Kamera hineingezoomt */
zoom: boolean;
/** Ziel des Zeigers und Zähler der Klicks (jede neue Zahl = eine Klickwelle) */
zeiger: ChatZiel;
klick: number;
/** Nachrichten blenden aus (Übergang zur nächsten Runde) */
aus: boolean;
runde: number;
statisch: boolean;
};
type Schritt = {
dauer: number;
sichtbar: number;
zoom: boolean;
/** Index der Nachricht, deren Text in dieser Zeit getippt wird */
tippt?: number;
/** Index der Nachricht, deren Text fertig im Feld steht */
fertig?: number;
anhang?: boolean;
aufnahme?: boolean;
zeiger?: ChatZiel;
klick?: boolean;
aus?: boolean;
};
/** Millisekunden je getipptem Zeichen */
const TIPPEN_MS = 20;
const tippdauer = (i: number) => (NACHRICHTEN[i].text?.length ?? 0) * TIPPEN_MS + 250;
const SCHRITTE: readonly Schritt[] = [
{ dauer: 700, sichtbar: 0, zoom: false },
{ dauer: 1100, sichtbar: 0, zoom: true },
// Foto anhängen
{ dauer: 700, sichtbar: 0, zoom: true, zeiger: "anhang" },
{ dauer: 500, sichtbar: 0, zoom: true, zeiger: "anhang", klick: true, anhang: true },
{ dauer: tippdauer(0), sichtbar: 0, zoom: true, anhang: true, tippt: 0 },
{ dauer: 650, sichtbar: 0, zoom: true, anhang: true, fertig: 0, zeiger: "senden" },
{ dauer: 2400, sichtbar: 1, zoom: true, zeiger: "senden", klick: true },
// Kenso antwortet
{ dauer: 2800, sichtbar: 2, zoom: true },
// Sprachnachricht
{ dauer: 600, sichtbar: 2, zoom: true, zeiger: "mikro" },
{ dauer: 1700, sichtbar: 2, zoom: true, zeiger: "mikro", klick: true, aufnahme: true },
{ dauer: 1400, sichtbar: 3, zoom: true, zeiger: "mikro", klick: true },
{ dauer: 3400, sichtbar: 4, zoom: true },
// noch eine kurze Antwort von Marlene
{ dauer: tippdauer(4), sichtbar: 4, zoom: true, tippt: 4 },
{ dauer: 550, sichtbar: 4, zoom: true, fertig: 4, zeiger: "senden" },
{ dauer: 1800, sichtbar: 5, zoom: true, zeiger: "senden", klick: true },
{ dauer: 2600, sichtbar: 6, zoom: true },
// herauszoomen, kurz stehen lassen, ausblenden
{ dauer: 3000, sichtbar: 6, zoom: false },
{ dauer: 700, sichtbar: 6, zoom: false, aus: true },
];
const DAUERN = SCHRITTE.map((s) => s.dauer);
/** Gesamtdauer einer Runde in Millisekunden (für Tests und die Feinabstimmung der Bühne). */
export const CHAT_RUNDE_MS = DAUERN.reduce((a, b) => a + b, 0);
/** Anfangszustand: leerer Chat, nicht gezoomt (die Bühne zeigt das sofort, bevor der Taktgeber geladen ist). */
export const CHAT_START: ChatZustand = {
sichtbar: 0,
eingabe: "",
anhang: false,
aufnahme: null,
zoom: false,
zeiger: "ruhe",
klick: 0,
aus: false,
runde: 0,
statisch: false,
};
/** Endzustand ohne Bewegung: alles da, hineingezoomt, leeres Eingabefeld. */
const ENDE: Omit<ChatZustand, "runde"> = { ...CHAT_START, sichtbar: NACHRICHTEN.length, zoom: true, statisch: true };
/**
* Taktgeber der Demo. `laeuft` = Station aktiv, im Bild, Tab sichtbar, Bewegung erlaubt.
* Läuft sie nicht, steht die Demo im Anfangszustand (kein Timer); mit `statisch` im Endzustand.
*/
export const useChatAblauf = (laeuft: boolean, statisch: boolean): ChatZustand => {
const aktivLauf = laeuft && !statisch;
const { schritt, runde } = useAblauf(aktivLauf, DAUERN, true);
const s = SCHRITTE[Math.min(schritt, SCHRITTE.length - 1)];
// Tippen und Aufnahmezeit: innerhalb eines Schritts im Takt weiterzählen
const [takt, setTakt] = useState(0);
const zaehlt = aktivLauf && (s.tippt !== undefined || s.aufnahme === true);
useEffect(() => {
setTakt(0);
if (!zaehlt) return;
const start = Date.now();
const t = window.setInterval(() => setTakt(Date.now() - start), 40);
return () => window.clearInterval(t);
}, [zaehlt, schritt, runde]);
let eingabe = "";
if (s.tippt !== undefined) {
const text = NACHRICHTEN[s.tippt].text ?? "";
eingabe = Array.from(text).slice(0, Math.floor(takt / TIPPEN_MS)).join("");
} else if (s.fertig !== undefined) {
eingabe = NACHRICHTEN[s.fertig].text ?? "";
}
// Klickzähler: wächst mit jedem Schritt, der klickt (für die Klickwelle des Zeigers)
const klicks = SCHRITTE.slice(0, schritt + 1).filter((x) => x.klick).length;
const klick = s.klick ? runde * 10 + klicks : 0;
const aufnahme = s.aufnahme ? Math.min(12, 1 + Math.floor(takt / 140)) : null;
// Nur ein neues Objekt, wenn sich wirklich etwas ändert (der Takt läuft schneller als die Buchstaben)
return useMemo<ChatZustand>(
() =>
statisch
? { ...ENDE, runde }
: {
sichtbar: s.sichtbar,
eingabe,
anhang: s.anhang === true,
aufnahme,
zoom: s.zoom,
zeiger: s.zeiger ?? "ruhe",
klick,
aus: s.aus === true,
runde,
statisch: false,
},
[statisch, runde, s, eingabe, aufnahme, klick],
);
};
+383
View File
@@ -0,0 +1,383 @@
/*
* Chat-Demo (Station Chat): Live-Ebene über der Aufnahme des leeren Portal-Chats. Alle Klassen mit Präfix ch-.
* Desktop 1512 x 893 mit Kamera-Zoom, Handy 402 x 874. Farben und Formen wie im Portal (kundenbereich.css, chat-composer.css).
* Grundzustand statisch; Bewegung nur transform/opacity hinter prefers-reduced-motion: no-preference.
*/
.ch-seite {
position: absolute;
inset: 0;
overflow: hidden;
background: #0b0712;
}
/* Die Kamera: hineingezoomt füllt die Eingabezeile (822 px breit) das MacBook fast ganz, unten bleibt sie im Bild.
In der Station Chat gleitet das iPhone zur Seite (kundenportal.css), damit nichts das Gespräch verdeckt. */
.ch-kamera {
position: absolute;
left: 0;
top: 0;
width: 1512px;
height: 893px;
transform-origin: 0 0;
transform: scale(1) translate(0px, 0px);
}
.ch-kamera[data-zoom="ja"] {
transform: scale(1.8) translate(-472px, -246px);
}
.ch-kulisse {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
}
.ch-handy {
position: absolute;
inset: 0;
pointer-events: none;
}
.ch-ebene {
position: absolute;
inset: 0;
pointer-events: none;
color: #fff;
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 16px;
line-height: 1.4;
text-align: left;
letter-spacing: normal;
-webkit-font-smoothing: antialiased;
user-select: none;
}
.ch-ebene *,
.ch-ebene *::before,
.ch-ebene *::after {
box-sizing: border-box;
margin: 0;
}
/* ── Nachrichten ─────────────────────────────────────────────────────── */
.ch-ausschnitt {
position: absolute;
overflow: hidden;
}
.ch-liste {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.ch-liste[data-aus="ja"] {
opacity: 0;
}
.ch-blase {
max-width: 76%;
padding: 11px 15px 10px;
border-radius: 16px;
font-size: 17px;
word-break: break-word;
}
[data-variante="handy"] .ch-blase {
max-width: 84%;
font-size: 15px;
}
.ch-blase--ich {
align-self: flex-end;
background: rgb(157 61 254 / 0.26);
border: 1px solid rgb(157 61 254 / 0.5);
border-bottom-right-radius: 4px;
transform-origin: 100% 100%;
}
.ch-blase--team {
align-self: flex-start;
background: rgb(255 255 255 / 0.06);
border: 1px solid rgb(255 255 255 / 0.16);
border-bottom-left-radius: 4px;
transform-origin: 0 100%;
}
.ch-text {
display: block;
}
.ch-meta {
display: block;
margin-top: 4px;
font-size: 12.5px;
opacity: 0.6;
}
.ch-blase--foto {
padding: 6px 6px 9px;
}
.ch-blase--foto .ch-text,
.ch-blase--foto .ch-meta {
padding: 0 9px;
}
.ch-blase--foto .ch-text {
margin-top: 8px;
}
.ch-foto {
display: block;
width: auto;
height: 236px;
border-radius: 11px;
}
[data-variante="handy"] .ch-foto {
width: 100%;
height: auto;
}
/* Sprachnachricht wie im Portal: Abspielknopf, Wort, Leiste, Dauer */
.ch-audio {
display: flex;
align-items: center;
gap: 10px;
width: 260px;
max-width: 100%;
padding: 2px 0;
}
.ch-audio__play {
flex: none;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #fff;
color: #17102a;
}
.ch-audio__play svg {
width: 16px;
height: 16px;
}
.ch-audio__mitte {
flex: 1;
display: grid;
gap: 7px;
}
.ch-audio__wort {
font-size: 12px;
font-weight: 600;
opacity: 0.8;
}
.ch-audio__leiste {
display: block;
height: 6px;
border-radius: 999px;
background: rgb(255 255 255 / 0.2);
}
.ch-audio__zeit {
flex: none;
font-size: 12px;
font-variant-numeric: tabular-nums;
opacity: 0.8;
}
/* ── Eingabebereich ──────────────────────────────────────────────────── */
/* Anhang über dem Eingabefeld (wie die Vorschau im Portal vor dem Senden) */
.ch-anhang {
position: absolute;
width: 64px;
height: 64px;
border-radius: 12px;
border: 1px solid rgb(255 255 255 / 0.16);
overflow: hidden;
transform-origin: 0 100%;
}
.ch-anhang img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Getippter Text: deckt das Platzhalter-Feld der Aufnahme ab; ist der Text länger, bleibt sein Ende sichtbar */
.ch-feld {
position: absolute;
display: flex;
align-items: center;
justify-content: flex-end;
overflow: hidden;
background: #171719;
border: 1px solid rgb(255 255 255 / 0.16);
font-size: 16px;
white-space: nowrap;
}
[data-variante="handy"] .ch-feld {
background: #1b1b1d;
}
.ch-feld__text {
flex: none;
min-width: 100%;
}
.ch-feld__strich {
display: inline-block;
width: 2px;
height: 1.15em;
margin-left: 1px;
vertical-align: -0.2em;
background: #c9a2ff;
}
.ch-senden {
position: absolute;
display: grid;
place-items: center;
border-radius: 999px;
background: #9d3dfe;
color: #fff;
font-size: 16px;
font-weight: 700;
}
.ch-senden svg {
width: 19px;
height: 19px;
}
/* Sprachaufnahme: rote Leiste statt Anhang, Textfeld und Mikrofon */
.ch-aufnahme {
position: absolute;
display: flex;
align-items: center;
gap: 10px;
padding: 0 6px 0 16px;
border-radius: 999px;
border: 1px solid rgb(248 113 113 / 0.45);
background: #1e1416;
font-size: 15px;
}
.ch-aufnahme__punkt {
flex: none;
width: 10px;
height: 10px;
border-radius: 50%;
background: #f87171;
}
.ch-aufnahme__wort {
flex: none;
font-size: 13px;
color: rgb(255 255 255 / 0.6);
}
.ch-aufnahme__zeit {
flex: none;
min-width: 36px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.ch-aufnahme__pegel {
flex: 1;
min-width: 0;
height: 24px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
overflow: hidden;
color: #fca5a5;
}
.ch-aufnahme__pegel i {
flex: none;
width: 3px;
height: 100%;
border-radius: 2px;
background: currentColor;
transform: scaleY(var(--ch-h, 0.5));
}
.ch-aufnahme__stopp {
flex: none;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
}
.ch-aufnahme__stopp::before {
content: "";
width: 12px;
height: 12px;
border-radius: 3px;
background: #f87171;
}
/* ── Bewegung ────────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: no-preference) {
.ch-kamera {
transition: transform 1150ms cubic-bezier(0.65, 0, 0.35, 1);
}
/* neue Nachricht: ploppt aus ihrer Ecke auf, mit einem kleinen Nachfedern */
.ch-blase {
animation: ch-auf 560ms cubic-bezier(0.2, 0.9, 0.3, 1.25) both;
}
.ch-liste {
transition: opacity 600ms ease;
}
.ch-anhang {
animation: ch-auf 420ms cubic-bezier(0.2, 0.9, 0.3, 1.25) both;
}
.ch-feld__strich {
animation: ch-blinken 1s steps(1) infinite;
}
.ch-senden[data-druck="ja"] {
animation: ch-druck 320ms ease-out both;
}
.ch-aufnahme {
animation: ch-einblenden 220ms ease-out both;
}
.ch-aufnahme__punkt {
animation: ch-pulsieren 1.2s ease-in-out infinite;
}
.ch-aufnahme__pegel i {
animation: ch-pegel 520ms ease-in-out infinite alternate;
}
}
@keyframes ch-auf {
from {
opacity: 0;
transform: translateY(14px) scale(0.86);
}
45% {
opacity: 1;
}
to {
opacity: 1;
transform: none;
}
}
@keyframes ch-blinken {
50% {
opacity: 0;
}
}
@keyframes ch-druck {
40% {
transform: scale(0.9);
}
to {
transform: none;
}
}
@keyframes ch-einblenden {
from {
opacity: 0;
}
}
@keyframes ch-pulsieren {
50% {
opacity: 0.25;
}
}
@keyframes ch-pegel {
from {
transform: scaleY(0.15);
}
to {
transform: scaleY(var(--ch-h, 0.5));
}
}
+8 -1
View File
@@ -2,9 +2,10 @@ import { memo, useEffect } from "react";
import { useTerminAblauf, type TerminZustand } from "./termin/ablauf";
import { useHeatmapAblauf, type HeatmapZustand } from "./heatmap/ablauf";
import { usePinnwandAblauf, type PinnwandZustand } from "./pinnwand/ablauf";
import { useChatAblauf, type ChatZustand } from "./chat/ablauf";
/**
* Taktgeber der drei Funktions-Demos als unsichtbare Bauteile (rendern nichts, melden nur ihren Zustand).
* Taktgeber der Demos (drei Funktionen und der Chat) als unsichtbare Bauteile (rendern nichts, melden nur ihren Zustand).
* Sie liegen in einem eigenen Chunk, damit die Startseite die Ablauf-Logik erst lädt, wenn die Kundenportal-Bühne
* eine Demo-Station fast erreicht (Spezifikation 4). Die Bühne hält den gemeldeten Zustand und gibt denselben
* Zustand an Website, Handy-Ebene und Diagnose.
@@ -26,6 +27,12 @@ export const PinnwandSteuerung = memo(function PinnwandSteuerung({ laeuft, stati
return null;
});
export const ChatSteuerung = memo(function ChatSteuerung({ laeuft, statisch, onZustand }: Props<ChatZustand>) {
const zustand = useChatAblauf(laeuft, statisch);
useEffect(() => onZustand(zustand), [zustand, onZustand]);
return null;
});
/** `aktiv`: Station ist die aktive; sonst bleiben weder Maus-Zustand noch Auswahl der Heatmap hängen. */
export const HeatmapSteuerung = memo(function HeatmapSteuerung({
laeuft,
+91 -22
View File
@@ -6,10 +6,13 @@
*/
.kp {
/* Länge eines Funktionsschritts auf der Bühne; die normalen und die längeren Demo-Schritte (der Ablauf soll zu sehen
sein) plus der letzte Schritt ergeben die Scrollstrecke */
--kp-s: 45svh;
--kp-d: 70svh;
/* Länge eines Schritts auf der Bühne: normale Stationen, die Demos (der Ablauf soll zu sehen sein), die Live-Vorschau
(die Website läuft beim Scrollen durch) und der Chat. Mit dem letzten Schritt ergeben sie die Scrollstrecke der Hülle
(Summe setzt die Bühne inline, siehe HUELLE_HOEHE). */
--kp-l-normal: 45svh;
--kp-l-demo: 70svh;
--kp-l-vorschau: 110svh;
--kp-l-chat: 90svh;
--kp-ende: 120svh;
--kp-oben: calc(var(--header-h) + 24px);
--kp-buehne-h: calc(100svh - var(--header-h) - 48px);
@@ -18,8 +21,10 @@
.kp {
/* nichts bewegt sich im letzten Schritt, also braucht er keine lange Strecke */
--kp-ende: 50svh;
/* die Demos zeigen ihren Endzustand und brauchen keine lange Strecke */
--kp-d: 45svh;
/* die Demos zeigen ihren Endzustand, die Website steht still: keine lange Strecke nötig */
--kp-l-demo: 45svh;
--kp-l-vorschau: 45svh;
--kp-l-chat: 45svh;
}
}
@@ -37,10 +42,7 @@
.kp-huelle {
position: relative;
/* --kp-nn und --kp-nd (Zahl der normalen und der Demo-Schritte vor "Als App") setzt die Bühne inline */
height: calc(
var(--kp-buehne-h) + var(--kp-nn, 4) * var(--kp-s) + var(--kp-nd, 3) * var(--kp-d) + var(--kp-ende)
);
/* Höhe (Bühne + alle Schritte + letzter Schritt) setzt die Bühne inline aus den Schrittlängen */
}
/* unsichtbare Spur aus Markern: der IntersectionObserver beobachtet sie, die Bühne klebt darüber */
@@ -51,15 +53,13 @@
flex-direction: column;
pointer-events: none;
}
/* --kp-l (Länge des Schritts) setzt die Bühne je Marke inline */
.kp-marker {
flex: 0 0 var(--kp-s);
flex: 0 0 var(--kp-l, var(--kp-l-normal));
}
/* Die erste Marke reicht bis zur Mitte des Bildschirms, wenn die Bühne anklebt: Schritt 1 ist beim Ankommen aktiv */
.kp-marker--demo {
flex-basis: var(--kp-d);
}
.kp-marker:first-child {
flex-basis: calc(50svh - var(--kp-oben) + var(--kp-s));
flex-basis: calc(50svh - var(--kp-oben) + var(--kp-l, var(--kp-l-normal)));
}
.kp-marker:last-child {
flex: 1 1 auto;
@@ -184,12 +184,36 @@
.kp-gruppe[data-offen="ja"] .kp-eintrag--gruppe .kp-eintrag__titel {
color: var(--fg);
}
/* kleiner Pfeil hinter „Funktionen“: zeigt nach rechts, solange zu, nach unten, wenn offen */
.kp-eintrag__pfeil {
display: inline-block;
width: 0.7em;
height: 0.7em;
margin-left: 0.3em;
vertical-align: 0.05em;
color: var(--fg-muted);
transform: rotate(-90deg);
}
.kp-gruppe[data-offen="ja"] .kp-eintrag__pfeil {
transform: none;
}
.kp-unterliste {
position: relative;
display: flex;
flex-direction: column;
margin: 2px 0 4px;
padding-left: 14px;
border-left: 1px solid var(--line-strong);
padding-left: 15px;
}
/* die senkrechte Linie links der Unterpunkte: wächst beim Aufklappen von oben, schrumpft beim Zuklappen nach oben */
.kp-unterliste::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 1px;
background: var(--line-strong);
transform-origin: 50% 0;
}
.kp-unterliste[hidden] {
display: none;
@@ -229,26 +253,63 @@
}
}
@media (prefers-reduced-motion: no-preference) {
.kp-eintrag__pfeil {
transition: transform var(--dur-enter) var(--ease);
}
/* Aufklappen: Linie wächst, die Unterpunkte gleiten nacheinander herein ("Als App" gleitet per FLIP nach unten) */
.kp-unterliste:not([hidden])::before {
animation: kp-linie-auf 380ms var(--ease) both;
}
.kp-unterliste:not([hidden]) > li {
animation: kp-aufklappen var(--dur-enter) var(--ease) both;
animation: kp-aufklappen 380ms var(--ease) both;
}
.kp-unterliste:not([hidden]) > li:nth-child(2) {
animation-delay: 60ms;
animation-delay: 50ms;
}
.kp-unterliste:not([hidden]) > li:nth-child(3) {
animation-delay: 120ms;
animation-delay: 100ms;
}
/* Zuklappen: die Unterpunkte falten sich von unten nach oben ein, die Linie zieht sich zurück; danach wird die
Unterliste versteckt und "Als App" gleitet per FLIP nach oben (die Bühne wartet 260 ms) */
.kp-gruppe[data-zustand="schliesst"] .kp-unterliste::before {
animation: kp-linie-zu 240ms ease-in both;
}
.kp-gruppe[data-zustand="schliesst"] .kp-unterliste > li:nth-child(n) {
animation: kp-zuklappen 190ms ease-in both;
}
.kp-gruppe[data-zustand="schliesst"] .kp-unterliste > li:nth-child(2) {
animation-delay: 30ms;
}
.kp-gruppe[data-zustand="schliesst"] .kp-unterliste > li:nth-child(1) {
animation-delay: 60ms;
}
}
@keyframes kp-aufklappen {
from {
opacity: 0;
transform: translateY(-6px);
transform: translateY(-10px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes kp-zuklappen {
to {
opacity: 0;
transform: translateY(-10px) scale(0.97);
}
}
@keyframes kp-linie-auf {
from {
transform: scaleY(0);
}
}
@keyframes kp-linie-zu {
to {
transform: scaleY(0);
}
}
/* Geräte: Desktop-Fenster mit überlappendem Handy-Rahmen unten rechts */
.kp-geraete {
@@ -378,6 +439,12 @@
.kp-buehne[data-app="ja"] .kp-handy {
transform: translate(-35.9cqw, -14.2cqw) scale(1.5);
}
/* Station Chat: das iPhone gleitet nach rechts unten weg, damit es das hineingezoomte Gespräch im MacBook nicht verdeckt */
.kp-buehne[data-station="chat"] .kp-handy {
opacity: 0;
transform: translate(10cqw, 6cqw) scale(0.94);
pointer-events: none;
}
/* Der lange Screenshot: gleiche Breite wie der Schirm, 65,93 % seiner Höhe wandern aus dem Bild
(sichtbar sind 874 von 2565,5 px im iPhone-Inhalt, 1 - 1748 / 5131). Die Verschiebung hängt am Scrollfortschritt (--kp-q), kein eigener Scrollbereich. */
@@ -400,7 +467,9 @@
transition: opacity var(--dur-enter) var(--ease);
}
.kp-handy {
transition: transform var(--dur-enter) var(--ease);
transition:
transform var(--dur-enter) var(--ease),
opacity var(--dur-enter) var(--ease);
}
.kp-buehne .kp-lang {
transform: translateY(calc(var(--kp-q, 0) * -65.93%));