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>
No files matched your search
@@ -23,3 +23,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Rohfotos der Beispiel-Bäckerei (werden von scripts/relaunch-baeckerei-fotos.py von Wikimedia Commons geladen)
|
||||
docs/relaunch/assets/baeckerei/roh/
|
||||
@@ -0,0 +1,17 @@
|
||||
# Fotos der Beispiel-Website „Bäckerei Sonnenschein“
|
||||
|
||||
Die Bäckerei ist erfunden (Demo-Kundin des Kundenportals). Die Fotos zeigen nur Backwaren, keine Menschen.
|
||||
Alle stammen von Wikimedia Commons und stehen unter **CC0 1.0** (gemeinfrei, keine Namensnennung nötig).
|
||||
Verkleinert und zugeschnitten mit `scripts/relaunch-baeckerei-fotos.py` nach `public/relaunch/demos/baeckerei/`.
|
||||
|
||||
| Datei in public | Commons-Datei |
|
||||
| --- | --- |
|
||||
| roggenbrot-600.webp | [Rye bread Roggenbengel 2025-02-14 13.jpg](https://commons.wikimedia.org/wiki/File:Rye_bread_Roggenbengel_2025-02-14_13.jpg) |
|
||||
| roggenbrot-anschnitt-400.webp | [Cut rye bread Kartoffelkruste 2025-02-20 02.jpg](https://commons.wikimedia.org/wiki/File:Cut_rye_bread_Kartoffelkruste_2025-02-20_02.jpg) |
|
||||
| broetchen-400.webp | [Bread-rolls.jpg](https://commons.wikimedia.org/wiki/File:Bread-rolls.jpg) |
|
||||
| croissants-400.webp | [Freshippo croissant bread.jpg](https://commons.wikimedia.org/wiki/File:Freshippo_croissant_bread.jpg) |
|
||||
| zimtschnecken-400.webp | [IKEA cinnamon bun.jpg](https://commons.wikimedia.org/wiki/File:IKEA_cinnamon_bun.jpg) (Ausschnitt ohne Logo oder Schrift) |
|
||||
| baguettes-700.webp | [Baguette-246424.jpg](https://commons.wikimedia.org/wiki/File:Baguette-246424.jpg) |
|
||||
| zwetschgenkuchen-520.webp | [Plum cake, Ehrenbach.jpg](https://commons.wikimedia.org/wiki/File:Plum_cake,_Ehrenbach.jpg) |
|
||||
|
||||
Lizenz von jeder Datei am 2026-10-04 über die Commons-API geprüft (`LicenseShortName` = CC0).
|
||||
|
After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,63 @@
|
||||
# Kundenportal-Bühne: Live-Vorschau, Chat-Demo und weiches Zuklappen
|
||||
|
||||
Stand 2026-10-04. Wunsch des Inhabers (diktiert): Das Durchscrollen der Bühne von „Live-Vorschau“ bis „Als App“ soll schöner werden.
|
||||
|
||||
1. Die Testseite in der Live-Vorschau wirkt primitiv. Sie soll wie eine richtige Website aussehen, auch übergreifend im Chat.
|
||||
2. Beim Herausscrollen aus „Funktionen“ soll das Zuklappen der Unterpunkte schöner sein.
|
||||
3. Der Chat bekommt eine erklärende Animation: Nachrichten ploppen auf, es wird hineingezoomt, damit man sie lesen kann.
|
||||
Die Texte sollen spielerisch sein, nicht kalt.
|
||||
|
||||
## 1. Live-Vorschau
|
||||
|
||||
- Im MacBook steht jetzt die Beispiel-Website selbst (`demos/baeckerei/BaeckereiSeite.tsx`), nicht mehr die Portal-Aufnahme.
|
||||
Die Adresse in der Safari-Leiste ist `baeckerei-sonnenschein.project.webklar.com`, also dieselbe wie im Portal.
|
||||
- Die Seite ist eine ganze Website: Leiste, Kopf, Startbereich („Frisch gebacken. Jeden Morgen.“), Band, Sortiment mit vier
|
||||
Karten, Backstube, Torten, Öffnungszeiten mit Stadtplan und Fuß. Die Schrift ist Instrument Serif (selbst gehostet, OFL)
|
||||
zusammen mit Instrument Sans.
|
||||
- Die Fotos stammen von Wikimedia Commons und stehen unter CC0. Sie zeigen nur Backwaren, keine Menschen.
|
||||
Quellen stehen in `docs/relaunch/assets/baeckerei/QUELLEN.md`, das Skript ist `scripts/relaunch-baeckerei-fotos.py`.
|
||||
- Scroll-Erlebnis: Die Station ist länger (`--kp-l-vorschau: 110svh`). Beim Scrollen durch die Station läuft die Website im
|
||||
MacBook einmal von oben nach unten durch. Die Bühne schreibt dafür `--kp-v` (0 bis 1); bewegt wird nur `transform`.
|
||||
Bei reduzierter Bewegung steht die Website oben, und die Station ist kurz.
|
||||
- Das iPhone zeigt die Projektübersicht des Portals. Die Aufnahme ist neu und zeigt dieselbe Website im Vorschaukasten.
|
||||
Das gilt auch für die lange Aufnahme in „Als App“. Aufgenommen wurde mit der Fixture des Portals; `demo-site.html` wird mit
|
||||
`gen-baeckerei.mjs` aus der Komponente erzeugt.
|
||||
- Hinweiskarte: „Geschützt: Ihre Vorschau öffnet sich nur nach der Anmeldung im Kundenportal – Fremde sehen nichts.“
|
||||
Das stimmt: `/api/auth/preview-access` lässt nur Kundensitzungen des Projekts und das Team durch.
|
||||
|
||||
## 2. Chat-Demo
|
||||
|
||||
- Im MacBook ist der Portal-Chat zu sehen. Darunter liegt die Aufnahme des leeren Chats (`desktop-3c-chat-leer`, auch in
|
||||
2400 px, damit sie beim Zoomen scharf bleibt). Darüber liegt eine Live-Ebene (`demos/chat/`) mit den Blasen, dem
|
||||
Eingabefeld, dem Anhang, der Sprachaufnahme, dem Senden-Knopf und einem Zeiger. Formen und Farben sind wie im Portal.
|
||||
- Ablauf (etwa 28 s, Schleife): Zuerst die Übersicht, dann zoomt die Kamera auf das Gespräch (1,8-fach, nur `transform`).
|
||||
Marlene hängt ein Foto an und tippt Zeichen für Zeichen. Danach wird gesendet, Kenso antwortet, und Marlene schickt eine
|
||||
Sprachnachricht. Es folgen noch eine Antwort, eine Nachricht von Marlene und die letzte Antwort. Dann zoomt die Kamera
|
||||
heraus, und die Nachrichten blenden aus.
|
||||
Neue Blasen ploppen aus ihrer Ecke auf und federn leicht nach. Die älteren Blasen rutschen weich nach oben (FLIP).
|
||||
- Texte (Sie-Form vom Team, warm und spielerisch):
|
||||
- Marlene: „Guten Morgen! ☀️ Unser neues Roggenbrot ist gerade aus dem Ofen. Darf das auf die Startseite?“ (mit Foto)
|
||||
- Kenso: „Und wie das darf! 😍 Das Foto ist so knusprig, ich rieche es bis hierher.“
|
||||
- Marlene: Sprachnachricht 0:12
|
||||
- Kenso: „Alles notiert: Brot ganz nach oben, Preis dazu, Öffnungszeiten etwas größer. Schauen Sie mal in die Vorschau! 🥖“
|
||||
- Marlene: „Sieht super aus! Morgen gibt’s Croissants fürs ganze Team. 🥐“
|
||||
- Kenso: „Dann stellen wir schon mal den Kaffee an. ☕“
|
||||
- Das Foto im Chat ist dasselbe Roggenbrot wie auf der Website, denn die Geschichte geht über beide Stationen.
|
||||
- Die Uhrzeiten (07:52 bis 10:31) versprechen keine Sofort-Antwort. Ein Tippen-Hinweis des Teams wird nicht gezeigt, weil
|
||||
das Portal keinen hat.
|
||||
- In der Station Chat gleitet das iPhone nach rechts unten weg. Sonst würde es die eigenen Nachrichten und den
|
||||
Senden-Knopf verdecken.
|
||||
- Hinweiskarte: „Antwortet das Team, kommt zusätzlich eine E-Mail – so verpassen Sie keine Nachricht.“
|
||||
Das stimmt: Bei jeder Team-Nachricht geht die Mail K17 hinaus, gedrosselt.
|
||||
- In der Wisch-Reihe auf dem Handy läuft dasselbe Gespräch über der Handy-Aufnahme des leeren Chats, dort ohne Zoom.
|
||||
- Bei reduzierter Bewegung steht sofort der Endzustand da: alle Nachrichten, hineingezoomt.
|
||||
|
||||
## 3. Weiches Auf- und Zuklappen von „Funktionen“
|
||||
|
||||
- Zustand `zu`, `offen` oder `schliesst`. Beim Verlassen der drei Funktionen falten sich die Unterpunkte von unten nach
|
||||
oben ein, und die Linie links zieht sich nach oben zurück. Das dauert etwa 260 ms. Danach verschwindet die Unterliste.
|
||||
- „Als App“ springt dabei nicht, sondern gleitet per FLIP von der alten an die neue Stelle (Web Animations API, nur
|
||||
`transform`). Beim Aufklappen gleitet es genauso nach unten, und die Unterpunkte gleiten nacheinander herein.
|
||||
- Ein kleiner Pfeil hinter „Funktionen“ zeigt den Zustand: nach rechts heißt zu, nach unten heißt offen.
|
||||
- Für Screenreader ist die Gruppe sofort zu (`aria-expanded="false"`, die Unterliste ist während des Einfaltens
|
||||
`aria-hidden`). Bei reduzierter Bewegung klappt die Gruppe sofort zu.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Relaunch: Fotos der erfundenen Beispiel-Website „Bäckerei Sonnenschein“ (Station Live-Vorschau und Chat-Demo).
|
||||
|
||||
Alle Fotos stammen von Wikimedia Commons und stehen unter CC0 (gemeinfrei, keine Namensnennung nötig).
|
||||
Es sind nur Backwaren zu sehen, keine Menschen. Quellen: docs/relaunch/assets/baeckerei/QUELLEN.md.
|
||||
|
||||
Wikimedia Commons (einmalig, Zwischenspeicher docs/relaunch/assets/baeckerei/roh/, nicht im Repository)
|
||||
-> public/relaunch/demos/baeckerei/<name>-<breite>.webp
|
||||
|
||||
Aufruf (aus dem Projektverzeichnis): python3 scripts/relaunch-baeckerei-fotos.py
|
||||
Braucht Python 3 und Pillow mit WebP-Unterstützung.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from PIL import Image
|
||||
|
||||
WURZEL = Path(__file__).resolve().parent.parent
|
||||
ROH = WURZEL / "docs" / "relaunch" / "assets" / "baeckerei" / "roh"
|
||||
ZIEL = WURZEL / "public" / "relaunch" / "demos" / "baeckerei"
|
||||
KENNUNG = {"User-Agent": "WEBklar-relaunch/1.0 (support@webklar.com)"}
|
||||
|
||||
KB = 1024
|
||||
QUALITAET = 78
|
||||
MIN_QUALITAET = 50
|
||||
|
||||
# Name -> (Datei auf Commons, Ausschnitt als Anteile (links, oben, rechts, unten), Zielbreiten, Seitenverhältnis B/H, Budget je Datei)
|
||||
FOTOS = {
|
||||
# Startseite und Chat-Foto: dasselbe Roggenbrot (Marlene schickt es im Chat, danach steht es auf der Website)
|
||||
"roggenbrot": ("Rye bread Roggenbengel 2025-02-14 13.jpg", (0.0, 0.0, 1.0, 1.0), (600,), 1.08, 58 * KB),
|
||||
"roggenbrot-anschnitt": ("Cut rye bread Kartoffelkruste 2025-02-20 02.jpg", (0.12, 0.12, 0.92, 0.95), (400,), 0.8, 30 * KB),
|
||||
"broetchen": ("Bread-rolls.jpg", (0.0, 0.0, 1.0, 1.0), (400,), 0.8, 30 * KB),
|
||||
"croissants": ("Freshippo croissant bread.jpg", (0.05, 0.0, 0.95, 1.0), (400,), 0.8, 34 * KB),
|
||||
"zimtschnecken": ("IKEA cinnamon bun.jpg", (0.05, 0.0, 0.95, 1.0), (400,), 0.8, 30 * KB),
|
||||
"baguettes": ("Baguette-246424.jpg", (0.0, 0.0, 1.0, 1.0), (700,), 1.2, 40 * KB),
|
||||
"zwetschgenkuchen": ("Plum cake, Ehrenbach.jpg", (0.18, 0.08, 1.0, 1.0), (520,), 1.0, 50 * KB),
|
||||
}
|
||||
|
||||
|
||||
def lade(datei: str) -> Path:
|
||||
"""Lädt das Foto einmal in 2000 px Breite von Commons (Special:FilePath) und hält es im Zwischenspeicher."""
|
||||
ROH.mkdir(parents=True, exist_ok=True)
|
||||
ziel = ROH / datei.replace(" ", "_")
|
||||
if not ziel.exists():
|
||||
url = "https://commons.wikimedia.org/w/index.php?title=Special:FilePath/" + urllib.parse.quote(datei) + "&width=2000"
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers=KENNUNG), timeout=60) as antwort:
|
||||
ziel.write_bytes(antwort.read())
|
||||
return ziel
|
||||
|
||||
|
||||
def zuschneiden(bild: Image.Image, anteile: tuple, verhaeltnis: float) -> Image.Image:
|
||||
"""Schneidet erst den Ausschnitt aus, dann mittig auf das Seitenverhältnis."""
|
||||
b, h = bild.size
|
||||
l, o, r, u = anteile
|
||||
bild = bild.crop((round(l * b), round(o * h), round(r * b), round(u * h)))
|
||||
b, h = bild.size
|
||||
if b / h > verhaeltnis:
|
||||
neu = round(h * verhaeltnis)
|
||||
return bild.crop(((b - neu) // 2, 0, (b - neu) // 2 + neu, h))
|
||||
neu = round(b / verhaeltnis)
|
||||
return bild.crop((0, (h - neu) // 2, b, (h - neu) // 2 + neu))
|
||||
|
||||
|
||||
def speichern(bild: Image.Image, ziel: Path, budget: int) -> tuple[int, int]:
|
||||
qualitaet = QUALITAET
|
||||
while True:
|
||||
bild.save(ziel, "WEBP", quality=qualitaet, method=6)
|
||||
groesse = ziel.stat().st_size
|
||||
if groesse <= budget or qualitaet - 4 < MIN_QUALITAET:
|
||||
return groesse, qualitaet
|
||||
qualitaet -= 4
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ZIEL.mkdir(parents=True, exist_ok=True)
|
||||
for alt in ZIEL.glob("*.webp"):
|
||||
alt.unlink()
|
||||
summe = 0
|
||||
for name, (datei, anteile, breiten, verhaeltnis, budget) in FOTOS.items():
|
||||
roh = zuschneiden(Image.open(lade(datei)).convert("RGB"), anteile, verhaeltnis)
|
||||
for breite in breiten:
|
||||
klein = roh.resize((breite, round(breite / verhaeltnis)), Image.LANCZOS)
|
||||
groesse, q = speichern(klein, ZIEL / f"{name}-{breite}.webp", budget)
|
||||
summe += groesse
|
||||
print(f"{name}-{breite}.webp {klein.size[0]}x{klein.size[1]} {groesse / KB:.1f} KB q{q}")
|
||||
print(f"Summe: {summe / KB:.0f} KB")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -43,15 +43,16 @@ LANG_HOEHE = 5131
|
||||
|
||||
# Dateiname (ohne .webp) -> (Art, Zielbreiten)
|
||||
BILDER = {
|
||||
# Desktop-Fenster (Roh 3024 x 1786 = MacBook-Safari-Fläche 1512 x 893 @2x)
|
||||
"desktop-1-projekte": ("desktop", (800, 1200, 1600)),
|
||||
# Desktop-Fenster (Roh 3024 x 1786 = MacBook-Safari-Fläche 1512 x 893 @2x); die Live-Vorschau zeigt im MacBook die
|
||||
# Beispiel-Website selbst (BaeckereiSeite), kein Bild
|
||||
"desktop-2-fortschritt": ("desktop", (800, 1200, 1600)),
|
||||
"desktop-3b-chat-foto": ("desktop", (800, 1200, 1600)),
|
||||
# Chat-Demo (Station Chat): leerer Chat als Kulisse, die Nachrichten sind live; 2400 px, weil die Kamera hineinzoomt
|
||||
"desktop-3c-chat-leer": ("desktop", (1600, 2400)),
|
||||
"desktop-4-rechnungen": ("desktop", (800, 1200, 1600)),
|
||||
# Handy (Roh 804 x 1748 = iPhone 16 Pro @2x, Safe Area 59/34; das lange Bild 804 x 5340, beschnitten auf 5131)
|
||||
"phone-1-projekte": ("handy", (390, 780)),
|
||||
"phone-3-projektdetails": ("handy", (390, 780)),
|
||||
"phone-4b-chat-foto": ("handy", (390, 780)),
|
||||
"phone-4c-chat-leer": ("handy", (390, 780)),
|
||||
"phone-5-rechnungen": ("handy", (390, 780)),
|
||||
# Funktions-Stationen (Spezifikation 2026-10-04-funktionen-demos.md): Handy-Ansicht der Funktion im Kundenportal
|
||||
"phone-9-termine": ("handy", (390, 780)),
|
||||
|
||||
@@ -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%));
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, renderHook, act, cleanup } from "@testing-library/react";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import ChatSeite from "@/components/home/demos/chat/ChatSeite";
|
||||
import ChatHandy from "@/components/home/demos/chat/ChatHandy";
|
||||
import BaeckereiSeite from "@/components/home/demos/baeckerei/BaeckereiSeite";
|
||||
import { useChatAblauf, CHAT_RUNDE_MS, CHAT_START, NACHRICHTEN, type ChatZustand } from "@/components/home/demos/chat/ablauf";
|
||||
|
||||
// Spezifikation 2026-10-04-vorschau-chat: Live-Vorschau (Bäckerei Sonnenschein) und Chat-Demo der Kundenportal-Bühne
|
||||
|
||||
const WURZEL = process.cwd();
|
||||
const ENDE: ChatZustand = { ...CHAT_START, sichtbar: NACHRICHTEN.length, zoom: true, statisch: true };
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Chat: Gespräch", () => {
|
||||
it("sechs Nachrichten im Wechsel, mit Foto und Sprachnachricht; das Team antwortet als Kenso", () => {
|
||||
expect(NACHRICHTEN).toHaveLength(6);
|
||||
expect(NACHRICHTEN.map((n) => n.von)).toEqual(["ich", "team", "ich", "team", "ich", "team"]);
|
||||
expect(NACHRICHTEN[0].foto).toBe(true);
|
||||
expect(NACHRICHTEN[2].sprache).toMatch(/^0:\d\d$/);
|
||||
// die Uhrzeiten laufen vorwärts
|
||||
const minuten = NACHRICHTEN.map((n) => Number(n.zeit.slice(0, 2)) * 60 + Number(n.zeit.slice(3)));
|
||||
expect([...minuten].sort((a, b) => a - b)).toEqual(minuten);
|
||||
});
|
||||
|
||||
it("verspricht nichts, was die Website nicht halten darf (Antwortzeit, Erfolg)", () => {
|
||||
const text = NACHRICHTEN.map((n) => n.text ?? "").join(" ");
|
||||
expect(text).not.toMatch(/sofort|in Minuten|24 Stunden|garantiert|mehr Kunden|mehr Umsatz/i);
|
||||
// das Team spricht die Kundin mit „Sie“ an
|
||||
expect(text).toContain("Schauen Sie");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat: Ablauf", () => {
|
||||
it("ohne Bewegung: sofort der Endzustand (alle Nachrichten, hineingezoomt), kein Timer", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() => useChatAblauf(true, true));
|
||||
expect(result.current.sichtbar).toBe(6);
|
||||
expect(result.current.zoom).toBe(true);
|
||||
expect(result.current.statisch).toBe(true);
|
||||
expect(result.current.eingabe).toBe("");
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("steht still, solange die Station nicht läuft (leerer Chat, nicht gezoomt)", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() => useChatAblauf(false, false));
|
||||
act(() => void vi.advanceTimersByTime(10_000));
|
||||
expect(result.current.sichtbar).toBe(0);
|
||||
expect(result.current.zoom).toBe(false);
|
||||
});
|
||||
|
||||
it("zoomt hinein, hängt das Foto an, tippt Zeichen für Zeichen, schickt ab; am Ende zoomt er heraus und beginnt von vorn", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() => useChatAblauf(true, false));
|
||||
// in kleinen Schritten: jeder Schritt des Ablaufs plant seinen Timer erst nach dem Rendern
|
||||
const vor = (ms: number) => {
|
||||
for (let t = 0; t < ms; t += 50) act(() => void vi.advanceTimersByTime(Math.min(50, ms - t)));
|
||||
};
|
||||
expect(result.current.zoom).toBe(false);
|
||||
vor(800);
|
||||
expect(result.current.zoom).toBe(true);
|
||||
vor(1800);
|
||||
expect(result.current.anhang).toBe(true);
|
||||
// während des Tippens wächst der Text im Feld
|
||||
vor(600);
|
||||
const teil = result.current.eingabe;
|
||||
expect(teil.length).toBeGreaterThan(0);
|
||||
expect(NACHRICHTEN[0].text?.startsWith(teil)).toBe(true);
|
||||
vor(600);
|
||||
expect(result.current.eingabe.length).toBeGreaterThan(teil.length);
|
||||
// bis zur letzten Nachricht wird jede sichtbar, nie springt die Zahl zurück
|
||||
let vorher = 0;
|
||||
let gesehenAufnahme = false;
|
||||
for (let t = 0; t < CHAT_RUNDE_MS - 5000; t += 100) {
|
||||
vor(100);
|
||||
if (result.current.aufnahme !== null) gesehenAufnahme = true;
|
||||
if (result.current.sichtbar < vorher) break;
|
||||
vorher = result.current.sichtbar;
|
||||
}
|
||||
expect(gesehenAufnahme).toBe(true);
|
||||
expect(vorher).toBe(6);
|
||||
// eine Runde später: wieder leer und nicht gezoomt
|
||||
vor(6000);
|
||||
expect(result.current.runde).toBeGreaterThan(0);
|
||||
expect(result.current.sichtbar).toBeLessThan(6);
|
||||
});
|
||||
|
||||
it("eine Runde dauert 20 bis 32 Sekunden (lang genug zum Lesen, kurz genug zum Zusehen)", () => {
|
||||
expect(CHAT_RUNDE_MS).toBeGreaterThan(20_000);
|
||||
expect(CHAT_RUNDE_MS).toBeLessThan(32_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat: Darstellung", () => {
|
||||
it("MacBook: Aufnahme des leeren Portal-Chats, darüber die sichtbaren Nachrichten; Kamera folgt dem Zustand", () => {
|
||||
const { container, rerender } = render(<ChatSeite zustand={ENDE} />);
|
||||
const wurzel = container.firstElementChild as HTMLElement;
|
||||
expect(wurzel).toHaveAttribute("aria-hidden", "true");
|
||||
expect(container.querySelector(".ch-kamera")).toHaveAttribute("data-zoom", "ja");
|
||||
expect(container.querySelector(".ch-kulisse")?.getAttribute("srcset")).toContain("desktop-3c-chat-leer-2400.webp 2400w");
|
||||
expect(container.querySelectorAll(".ch-blase")).toHaveLength(6);
|
||||
expect(container.querySelectorAll(".ch-blase--ich")).toHaveLength(3);
|
||||
expect(container.querySelector(".ch-audio")).toHaveTextContent("Sprachnachricht");
|
||||
expect(container.querySelectorAll("a, button, input, select, textarea, [tabindex]")).toHaveLength(0);
|
||||
|
||||
rerender(<ChatSeite zustand={{ ...CHAT_START, zoom: true, anhang: true, eingabe: "Guten Morgen" }} />);
|
||||
expect(container.querySelectorAll(".ch-blase")).toHaveLength(0);
|
||||
expect(container.querySelector(".ch-feld")).toHaveTextContent("Guten Morgen");
|
||||
expect(container.querySelector(".ch-anhang img")).not.toBeNull();
|
||||
// Senden ist aktiv, sobald Text im Feld steht
|
||||
expect(container.querySelector(".ch-senden")).toHaveTextContent("Senden");
|
||||
|
||||
rerender(<ChatSeite zustand={{ ...CHAT_START, zoom: true, sichtbar: 2, aufnahme: 7 }} />);
|
||||
expect(container.querySelector(".ch-aufnahme")).toHaveTextContent("Aufnahme0:07");
|
||||
expect(container.querySelector(".ch-feld")).toBeNull();
|
||||
});
|
||||
|
||||
it("iPhone: nur die Ebene (transparent), Senden als runder Knopf ohne Wort", () => {
|
||||
const { container } = render(<ChatHandy zustand={{ ...CHAT_START, eingabe: "Hallo", sichtbar: 1 }} />);
|
||||
expect(container.querySelector(".ch-handy")).toHaveAttribute("aria-hidden", "true");
|
||||
expect(container.querySelector("img.ch-kulisse")).toBeNull();
|
||||
expect(container.querySelector(".ch-ebene")).toHaveAttribute("data-variante", "handy");
|
||||
expect(container.querySelector(".ch-senden svg")).not.toBeNull();
|
||||
expect(container.querySelector(".ch-senden")).not.toHaveTextContent("Senden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bäckerei Sonnenschein (Live-Vorschau)", () => {
|
||||
it("ist eine ganze Website: Kopf, Start, Sortiment, Backstube, Torten, Öffnungszeiten, Fuß", () => {
|
||||
const { container } = render(<BaeckereiSeite />);
|
||||
const text = container.textContent ?? "";
|
||||
for (const teil of ["Sonnenschein", "Frisch gebacken.", "Jeden Morgen.", "Unser Sortiment", "Roggenbrot", "Unsere Backstube", "Kuchen & Torten", "Öffnungszeiten", "Impressum"]) {
|
||||
expect(text).toContain(teil);
|
||||
}
|
||||
const wurzel = container.firstElementChild as HTMLElement;
|
||||
expect(wurzel).toHaveAttribute("aria-hidden", "true");
|
||||
expect(container.querySelectorAll("a, button, input, select, textarea, iframe, [tabindex]")).toHaveLength(0);
|
||||
// gleiche Telefonnummer und gleiche Überschrift wie in den Portal-Aufnahmen (Fortschritt: „Frisch gebacken. Jeden Morgen.“)
|
||||
expect(text).toContain("01234 / 56 78 90");
|
||||
});
|
||||
|
||||
it("alle Fotos liegen selbst gehostet in public/ und stehen mit CC0-Quelle in QUELLEN.md", () => {
|
||||
const { container } = render(<BaeckereiSeite />);
|
||||
const quellen = readFileSync(`${WURZEL}/docs/relaunch/assets/baeckerei/QUELLEN.md`, "utf8");
|
||||
const bilder = Array.from(container.querySelectorAll("img")).map((b) => b.getAttribute("src")!);
|
||||
expect(bilder.length).toBeGreaterThanOrEqual(6);
|
||||
for (const src of bilder) {
|
||||
expect(src).toMatch(/^\/relaunch\/demos\/baeckerei\/[a-z-]+-\d+\.webp$/);
|
||||
const datei = `${WURZEL}/public${src}`;
|
||||
expect(existsSync(datei), src).toBe(true);
|
||||
expect(statSync(datei).size / 1024, src).toBeLessThanOrEqual(60);
|
||||
expect(quellen, src).toContain(src.split("/").pop()!);
|
||||
}
|
||||
// jede Datei im Ordner ist in QUELLEN.md belegt
|
||||
for (const datei of readdirSync(`${WURZEL}/public/relaunch/demos/baeckerei`)) expect(quellen, datei).toContain(datei);
|
||||
expect(quellen).toContain("CC0");
|
||||
});
|
||||
|
||||
it("die Schrift Instrument Serif ist selbst gehostet und mit Lizenz abgelegt", () => {
|
||||
const css = readFileSync(`${WURZEL}/src/components/home/demos/baeckerei/baeckerei.css`, "utf8");
|
||||
for (const m of css.matchAll(/url\((\/fonts\/[^)]+)\)/g)) expect(existsSync(`${WURZEL}/public${m[1]}`), m[1]).toBe(true);
|
||||
expect(readFileSync(`${WURZEL}/public/fonts/instrument-serif/LICENSE`, "utf8")).toContain("SIL Open Font License");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Quelltext", () => {
|
||||
const quelle = (pfad: string) => readFileSync(`${WURZEL}/src/components/home/demos/${pfad}`, "utf8");
|
||||
|
||||
it("ablauf.ts importiert kein CSS (die Komponenten werden per React.lazy geladen)", () => {
|
||||
expect(quelle("chat/ablauf.ts")).not.toMatch(/import\s+["'][^"']+\.css["']|from\s+["'][^"']+\.css["']/);
|
||||
});
|
||||
|
||||
it("lädt nichts von fremden Servern", () => {
|
||||
for (const datei of ["chat/ChatEbene.tsx", "chat/ChatSeite.tsx", "chat/ChatHandy.tsx", "chat/chat.css", "baeckerei/BaeckereiSeite.tsx", "baeckerei/baeckerei.css"]) {
|
||||
expect(quelle(datei), datei).not.toMatch(/https?:\/\//);
|
||||
expect(quelle(datei), datei).not.toMatch(/googleapis|gstatic|unsplash/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("bewegt nur transform und opacity und nur ohne reduzierte Bewegung", () => {
|
||||
for (const datei of ["chat/chat.css", "baeckerei/baeckerei.css"]) {
|
||||
const css = quelle(datei).replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
for (const treffer of css.matchAll(/@keyframes[^{]+\{([\s\S]*?)\n\}/g)) {
|
||||
const eigenschaften = [...treffer[1].matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1]);
|
||||
for (const e of eigenschaften) expect(["transform", "opacity"], datei).toContain(e);
|
||||
}
|
||||
for (const m of css.matchAll(/transition:\s*([^;]+);/g)) {
|
||||
// Kommas in cubic-bezier(...) trennen keine Übergänge
|
||||
for (const teil of m[1].split(/,(?![^(]*\))/)) expect(["transform", "opacity"], `${datei}: ${m[0]}`).toContain(teil.trim().split(/\s+/)[0]);
|
||||
}
|
||||
const vorBewegung = css.split("@media (prefers-reduced-motion: no-preference)")[0];
|
||||
expect(vorBewegung, datei).not.toMatch(/animation\s*:|transition\s*:/);
|
||||
// die Website läuft nur mit Bewegung mit (transform mit --kp-v steht in der Medienabfrage)
|
||||
if (datei.startsWith("baeckerei")) expect(vorBewegung).not.toContain("--kp-v");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,8 @@ import { join } from "node:path";
|
||||
import Kundenportal from "@/components/home/Kundenportal";
|
||||
import GeraeteRahmen, { DesktopFenster, HandyRahmen } from "@/components/GeraeteRahmen";
|
||||
|
||||
// Spezifikation 7.2 (Kundenportal) und 9.5 (Bilder); Funktions-Demos: 2026-10-04-funktionen-demos (Stationen 5 bis 7)
|
||||
// Spezifikation 7.2 (Kundenportal) und 9.5 (Bilder); Funktions-Demos: 2026-10-04-funktionen-demos (Stationen 5 bis 7);
|
||||
// Live-Vorschau, Chat-Demo und weiches Auf-/Zuklappen: 2026-10-04-vorschau-chat
|
||||
|
||||
const WURZEL = `${process.cwd()}/`;
|
||||
const BILDORDNER = join(WURZEL, "public/relaunch/portal");
|
||||
@@ -29,11 +30,15 @@ const SCHRITTE = [
|
||||
"Online – Hosting und Pflege auf Wunsch im Abo",
|
||||
];
|
||||
|
||||
/** matchMedia: die Abfrage "ab 900 px" ist wahr, wenn desktop = true; "hover: hover" (feiner Zeiger, Maus), wenn fein = true. */
|
||||
const mitBreite = (desktop: boolean, fein = desktop) => {
|
||||
/** matchMedia: die Abfrage "ab 900 px" ist wahr, wenn desktop = true; "hover: hover" (feiner Zeiger, Maus), wenn fein = true;
|
||||
* "prefers-reduced-motion: no-preference" (Bewegung erlaubt) nur, wenn bewegung = true (sonst zeigen die Demos ihren Endzustand). */
|
||||
const mitBreite = (desktop: boolean, fein = desktop, bewegung = false) => {
|
||||
const vorher = window.matchMedia;
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: (desktop && query.includes("min-width: 900px")) || (fein && query.includes("hover: hover")),
|
||||
matches:
|
||||
(desktop && query.includes("min-width: 900px")) ||
|
||||
(fein && query.includes("hover: hover")) ||
|
||||
(bewegung && query.includes("prefers-reduced-motion: no-preference")),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
@@ -56,6 +61,11 @@ const zeigeDesktop = () => {
|
||||
zuruecksetzen = mitBreite(true);
|
||||
return render(<Kundenportal />);
|
||||
};
|
||||
/** Bühne ab 900 px mit erlaubter Bewegung (Demos laufen, die Gruppe klappt weich zu). */
|
||||
const zeigeDesktopBewegt = () => {
|
||||
zuruecksetzen = mitBreite(true, true, true);
|
||||
return render(<Kundenportal />);
|
||||
};
|
||||
/** Bühne ab 900 px, aber ohne Maus (Tablet quer). */
|
||||
const zeigeTablet = () => {
|
||||
zuruecksetzen = mitBreite(true, false);
|
||||
@@ -125,7 +135,7 @@ describe("Kundenportal: Text und Reihenfolge", () => {
|
||||
|
||||
it("Tablet quer (Bühne ohne Maus): der Bedienhinweis der Website-Analyse sagt „Tippen Sie“ statt „Zeigen Sie mit der Maus“", () => {
|
||||
const { container } = zeigeTablet();
|
||||
const hinweis = container.querySelectorAll(".kp-beispiel")[1];
|
||||
const hinweis = Array.from(container.querySelectorAll(".kp-beispiel")).find((h) => h.textContent?.includes("Tippen"));
|
||||
expect(hinweis).toHaveTextContent("Tippen Sie auf eine Stelle der Seite.");
|
||||
expect(hinweis).not.toHaveTextContent("Maus");
|
||||
});
|
||||
@@ -177,9 +187,8 @@ describe("Kundenportal: Bilder (Spezifikation 9.5)", () => {
|
||||
it("Desktop: zu Beginn hängen nur die ersten beiden Schritte ihre Bilder ein (spart Daten)", () => {
|
||||
const { container } = zeigeDesktop();
|
||||
const bilder = Array.from(container.querySelectorAll("img"));
|
||||
// je zwei Desktop- und zwei Handy-Bilder
|
||||
// die Live-Vorschau zeigt im MacBook die Beispiel-Website (wird nachgeladen), dazu Fortschritt und zwei Handy-Bilder
|
||||
expect(bilder.map((b) => b.getAttribute("src"))).toEqual([
|
||||
"/relaunch/portal/desktop-1-projekte-1200.webp",
|
||||
"/relaunch/portal/desktop-2-fortschritt-1200.webp",
|
||||
"/relaunch/portal/phone-1-projekte-780.webp",
|
||||
"/relaunch/portal/phone-3-projektdetails-780.webp",
|
||||
@@ -206,8 +215,12 @@ describe("Kundenportal: Bilder (Spezifikation 9.5)", () => {
|
||||
|
||||
it("in public/ liegen nur die Bilder, die der Abschnitt zeigt (keine Rohexporte, keine unbeworbenen Ansichten)", () => {
|
||||
const skript = readFileSync(join(WURZEL, "scripts/relaunch-bilder.py"), "utf8");
|
||||
// die alten Ansichten der News-Pinnwand ersetzt die Live-Demo (Station 7): ihre Bilder werden nicht mehr ausgeliefert
|
||||
// die alten Ansichten der News-Pinnwand ersetzt die Live-Demo (Station 7), Live-Vorschau und Chat sind ebenfalls live:
|
||||
// ihre alten Bilder werden nicht mehr ausgeliefert
|
||||
for (const nicht of [
|
||||
"desktop-1-projekte",
|
||||
"desktop-3b-chat-foto",
|
||||
"phone-4b-chat-foto",
|
||||
"desktop-5-stundenkonto",
|
||||
"desktop-6-funktionen",
|
||||
"phone-6-vertraege",
|
||||
@@ -218,9 +231,10 @@ describe("Kundenportal: Bilder (Spezifikation 9.5)", () => {
|
||||
expect(skript, nicht).not.toContain(nicht);
|
||||
expect(readdirSync(BILDORDNER).filter((d) => d.startsWith(nicht)), nicht).toEqual([]);
|
||||
}
|
||||
// nur Breiten aus der Spezifikation (Desktop 800/1200/1600, Handy 390/780)
|
||||
// nur Breiten aus der Spezifikation (Desktop 800/1200/1600, Handy 390/780); der leere Chat zusätzlich in 2400 px,
|
||||
// weil die Kamera der Chat-Demo hineinzoomt
|
||||
for (const datei of readdirSync(BILDORDNER)) {
|
||||
expect(datei).toMatch(/^(desktop-.+-(800|1200|1600)|phone-.+-(390|780))\.webp$/);
|
||||
expect(datei).toMatch(/^(desktop-.+-(800|1200|1600)|desktop-3c-chat-leer-2400|phone-.+-(390|780))\.webp$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -352,8 +366,9 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
// der lange Screenshot ist jetzt eingehängt und aktiv
|
||||
const lang = container.querySelector(".kp-handy .kp-lage.is-aktiv .kp-lang img");
|
||||
expect(lang?.getAttribute("src")).toContain("phone-2-projekte-lang");
|
||||
// alle früheren Bilder sind inzwischen eingehängt
|
||||
expect(container.querySelectorAll("img").length).toBe(4 + 8);
|
||||
// alle früheren Bilder sind inzwischen eingehängt: zwei Desktop-Aufnahmen (Fortschritt, Rechnungen) und acht Handy-Aufnahmen
|
||||
// (die Live-Stationen zeigen im MacBook Website bzw. Chat, deren Bilder liegen tiefer in der Ebene)
|
||||
expect(container.querySelectorAll(".kp-lage > img, .kp-lage > .kp-lang > img").length).toBe(2 + 8);
|
||||
});
|
||||
|
||||
it("Demo-Stationen: die drei Websites werden nachgeladen, sobald die Bühne sie fast erreicht, und aktiv ist nur eine", async () => {
|
||||
@@ -391,6 +406,8 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
const adresse = () => container.querySelector(".dm-macbook__url")?.textContent;
|
||||
expect(adresse()).toBe("baeckerei-sonnenschein.project.webklar.com");
|
||||
meldeMarke(1);
|
||||
expect(adresse()).toBe("project.webklar.com");
|
||||
meldeMarke(4);
|
||||
expect(adresse()).toBe("salon-schnittstelle.example");
|
||||
@@ -421,33 +438,100 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
// der Hinweis aufs erfundene Beispiel steht daneben; nur der der aktiven Station ist für Screenreader da
|
||||
const hinweise = Array.from(container.querySelectorAll(".kp-beispiel"));
|
||||
expect(hinweise.map((h) => h.textContent)).toEqual([
|
||||
"Erfundenes Beispiel · Bäckerei Sonnenschein",
|
||||
"Erfundenes Beispiel · Bäckerei Sonnenschein im Kundenportal",
|
||||
"Erfundenes Beispiel · salon-schnittstelle.example",
|
||||
// mit Maus (diese Bühne meldet eine feine Zeigereingabe): Hinweis zum Zeigen
|
||||
"Erfundenes Beispiel · Zeigen Sie mit der Maus auf die Seite.",
|
||||
"Erfundenes Beispiel · musterbach.example",
|
||||
]);
|
||||
expect(hinweise.map((h) => h.getAttribute("data-an"))).toEqual(["nein", "ja", "nein"]);
|
||||
expect(hinweise.map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "ja", "nein"]);
|
||||
|
||||
meldeMarke(6);
|
||||
expect(diagnose()).toHaveAttribute("data-an", "nein");
|
||||
expect(diagnose()).toHaveAttribute("inert");
|
||||
});
|
||||
|
||||
it("Terminplaner und Pinnwand tragen unter dem MacBook eine Hinweiskarte (Kalender-Abo mit Verzögerung, neuer Zettel); nur die der aktiven Station ist sichtbar", () => {
|
||||
it("Live-Vorschau, Chat, Terminplaner und Pinnwand tragen unter dem MacBook eine Hinweiskarte; nur die der aktiven Station ist sichtbar", () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
const hinweise = () => Array.from(container.querySelectorAll(".kp-hinweis")) as HTMLElement[];
|
||||
expect(hinweise().map((h) => h.textContent)).toEqual([
|
||||
// stimmt: Vorschauen öffnen sich nur mit Portal-Sitzung (api/auth/preview-access), das Team bekommt Zugriff übers Ticketsystem
|
||||
"Geschützt: Ihre Vorschau öffnet sich nur nach der Anmeldung im Kundenportal – Fremde sehen nichts.",
|
||||
// stimmt: Schreibt das Team im Chat, geht an den Kunden die Mail K17 (gedrosselt)
|
||||
"Antwortet das Team, kommt zusätzlich eine E-Mail – so verpassen Sie keine Nachricht.",
|
||||
"Kalender-Abo: Den Link einmal in Google Kalender, Apple Kalender oder Outlook einfügen. Neue Termine erscheinen dort mit etwas Verzögerung.",
|
||||
"Neuer Zettel: Er erscheint nach dem Aufhängen auf der Website.",
|
||||
]);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["ja", "nein", "nein", "nein"]);
|
||||
meldeMarke(1);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "nein"]);
|
||||
meldeMarke(2);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "ja", "nein", "nein"]);
|
||||
meldeMarke(4);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["ja", "nein"]);
|
||||
expect(hinweise().map((h) => h.hasAttribute("aria-hidden"))).toEqual([false, true]);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "ja", "nein"]);
|
||||
expect(hinweise().map((h) => h.hasAttribute("aria-hidden"))).toEqual([true, true, false, true]);
|
||||
meldeMarke(5);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein"]);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "nein"]);
|
||||
meldeMarke(6);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "ja"]);
|
||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "ja"]);
|
||||
});
|
||||
|
||||
it("Live-Vorschau: im MacBook läuft die Beispiel-Website der Bäckerei (nachgeladen, aria-hidden, nichts Fokussierbares)", async () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
await waitFor(() => expect(container.querySelector(".kp-fenster .kp-lage.is-aktiv .bk-seite")).not.toBeNull());
|
||||
const ebene = container.querySelector(".kp-fenster .kp-lage.is-aktiv") as HTMLElement;
|
||||
expect(ebene).toHaveAttribute("role", "img");
|
||||
expect(ebene.getAttribute("aria-label")).toMatch(/Bäckerei/);
|
||||
const seite = ebene.querySelector(".bk-seite") as HTMLElement;
|
||||
expect(seite).toHaveAttribute("aria-hidden", "true");
|
||||
expect(seite.querySelectorAll("a, button, input, select, textarea, [tabindex]")).toHaveLength(0);
|
||||
expect(seite).toHaveTextContent("Frisch gebacken.");
|
||||
// ohne Bewegung steht die Website oben (der Scrollfortschritt der Station bleibt 0)
|
||||
expect((container.querySelector(".kp-buehne") as HTMLElement).style.getPropertyValue("--kp-v")).toBe("0");
|
||||
});
|
||||
|
||||
it("Chat: im MacBook der Portal-Chat mit dem ganzen Gespräch (ohne Bewegung: Endzustand, hineingezoomt), das iPhone gleitet weg", async () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeDesktop();
|
||||
meldeMarke(2);
|
||||
expect(container.querySelector(".kp-buehne")).toHaveAttribute("data-station", "chat");
|
||||
await waitFor(() => expect(container.querySelectorAll(".kp-fenster .kp-lage.is-aktiv .ch-blase")).toHaveLength(6));
|
||||
const ebene = container.querySelector(".kp-fenster .kp-lage.is-aktiv") as HTMLElement;
|
||||
expect(ebene.getAttribute("aria-label")).toMatch(/Chat im Kundenportal/);
|
||||
expect(ebene.querySelector(".ch-kamera")).toHaveAttribute("data-zoom", "ja");
|
||||
expect(ebene.querySelector(".ch-seite")).toHaveAttribute("aria-hidden", "true");
|
||||
expect(ebene.querySelector(".ch-blase--ich .ch-foto")).not.toBeNull();
|
||||
expect(ebene).toHaveTextContent("Kenso · 08:41");
|
||||
// auf der Bühne gibt es keine Chat-Ebene im iPhone (es gleitet in dieser Station weg)
|
||||
expect(container.querySelector(".kp-handy .ch-ebene")).toBeNull();
|
||||
});
|
||||
|
||||
it("der Überpunkt „Funktionen“ faltet sich mit Bewegung erst ein und klappt dann zu", async () => {
|
||||
beobachterAttrappe();
|
||||
zeigeDesktopBewegt();
|
||||
const gruppe = screen.getByRole("button", { name: /^Funktionen/ });
|
||||
const liste = screen.getByRole("list", { name: "Funktionen des Kundenportals" });
|
||||
const unterliste = liste.querySelector<HTMLElement>("#kp-unterliste")!;
|
||||
meldeMarke(6);
|
||||
expect(gruppe).toHaveAttribute("aria-expanded", "true");
|
||||
meldeMarke(7);
|
||||
// sofort zu für Screenreader, sichtbar faltet sich die Unterliste noch ein
|
||||
expect(gruppe).toHaveAttribute("aria-expanded", "false");
|
||||
expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "schliesst");
|
||||
expect(unterliste).not.toHaveAttribute("hidden");
|
||||
expect(unterliste).toHaveAttribute("aria-hidden", "true");
|
||||
await waitFor(() => expect(unterliste).toHaveAttribute("hidden"));
|
||||
expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "zu");
|
||||
// geht sie während des Einfaltens wieder auf, bleibt sie offen
|
||||
meldeMarke(5);
|
||||
meldeMarke(7);
|
||||
meldeMarke(5);
|
||||
expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "offen");
|
||||
await new Promise((r) => setTimeout(r, 320));
|
||||
expect(unterliste).not.toHaveAttribute("hidden");
|
||||
});
|
||||
|
||||
it("die aktive Demo-Ebene trägt eine kurze Beschreibung für Screenreader, die Website darin bleibt aria-hidden", async () => {
|
||||
@@ -479,7 +563,9 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
it("Handy-Karten: die drei Websites laden erst, wenn die Karte in Sicht kommt; Tippen auf die Heatmap wählt eine Zone", async () => {
|
||||
beobachterAttrappe();
|
||||
const { container } = zeigeHandy();
|
||||
const karten = Array.from(container.querySelectorAll(".kp-karte--demo"));
|
||||
// drei Funktions-Karten (Websites) und die Chat-Karte
|
||||
expect(container.querySelectorAll(".kp-karte--demo")).toHaveLength(4);
|
||||
const karten = Array.from(container.querySelectorAll(".kp-karte--demo[data-demo]"));
|
||||
expect(karten).toHaveLength(3);
|
||||
// noch nichts gesehen: Rahmen leer, nichts geladen
|
||||
expect(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite)")).toHaveLength(0);
|
||||
@@ -505,6 +591,11 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
|
||||
expect(heat.querySelector(".hmd")).toHaveAttribute("data-auswahl", "routenbild");
|
||||
// Hinweis aufs erfundene Beispiel an jeder Demo-Karte
|
||||
for (const karte of karten) expect(karte.querySelector(".kp-karte__beispiel")?.textContent).toMatch(/^Erfundenes Beispiel · .+\.example$/);
|
||||
// Chat-Karte: die Portal-Aufnahme steht sofort da, darüber das Gespräch (ohne Bewegung: alle sechs Nachrichten)
|
||||
const chat = container.querySelector(".kp-karte--demo:not([data-demo])") as HTMLElement;
|
||||
expect(chat.querySelector("img")?.getAttribute("src")).toContain("phone-4c-chat-leer");
|
||||
await waitFor(() => expect(chat.querySelectorAll(".ch-blase")).toHaveLength(6));
|
||||
expect(chat.querySelector(".kp-karte__beispiel")?.textContent).toBe("Erfundenes Beispiel · Bäckerei Sonnenschein im Kundenportal");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||