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:
1 parent
58ac7745e6
commit
c1d775e090
47 files changed
+2537
-87
No files matched your search
@@ -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 & 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;
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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%));
|
||||
|
||||
Reference in new issue
Block a user