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
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.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)
|
# Dateiname (ohne .webp) -> (Art, Zielbreiten)
|
||||||
BILDER = {
|
BILDER = {
|
||||||
# Desktop-Fenster (Roh 3024 x 1786 = MacBook-Safari-Fläche 1512 x 893 @2x)
|
# Desktop-Fenster (Roh 3024 x 1786 = MacBook-Safari-Fläche 1512 x 893 @2x); die Live-Vorschau zeigt im MacBook die
|
||||||
"desktop-1-projekte": ("desktop", (800, 1200, 1600)),
|
# Beispiel-Website selbst (BaeckereiSeite), kein Bild
|
||||||
"desktop-2-fortschritt": ("desktop", (800, 1200, 1600)),
|
"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)),
|
"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)
|
# 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-1-projekte": ("handy", (390, 780)),
|
||||||
"phone-3-projektdetails": ("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)),
|
"phone-5-rechnungen": ("handy", (390, 780)),
|
||||||
# Funktions-Stationen (Spezifikation 2026-10-04-funktionen-demos.md): Handy-Ansicht der Funktion im Kundenportal
|
# Funktions-Stationen (Spezifikation 2026-10-04-funktionen-demos.md): Handy-Ansicht der Funktion im Kundenportal
|
||||||
"phone-9-termine": ("handy", (390, 780)),
|
"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 WischReihe from "@/components/WischReihe";
|
||||||
import { DesktopFenster, HandyRahmen, RahmenBild, type RahmenBildDaten } from "@/components/GeraeteRahmen";
|
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 { TerminZustand } from "./demos/termin/ablauf";
|
||||||
import type { HeatmapZustand } from "./demos/heatmap/ablauf";
|
import type { HeatmapZustand } from "./demos/heatmap/ablauf";
|
||||||
import type { PinnwandZustand } from "./demos/pinnwand/ablauf";
|
import type { PinnwandZustand } from "./demos/pinnwand/ablauf";
|
||||||
|
import type { ChatZustand } from "./demos/chat/ablauf";
|
||||||
import "./kundenportal.css";
|
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
|
* 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
|
* 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.
|
* 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 =
|
const EINLEITUNG =
|
||||||
@@ -51,6 +68,9 @@ const handyBild = (name: string, alt: string, sizes: string, hoehe = 1696): Rahm
|
|||||||
});
|
});
|
||||||
|
|
||||||
type DemoArt = "termin" | "heatmap" | "pinnwand";
|
type DemoArt = "termin" | "heatmap" | "pinnwand";
|
||||||
|
/** Live-Stationen außerhalb der Gruppe „Funktionen“ */
|
||||||
|
type LiveArt = "vorschau" | "chat";
|
||||||
|
type LadeArt = DemoArt | LiveArt;
|
||||||
|
|
||||||
type Funktion = {
|
type Funktion = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -64,13 +84,17 @@ type Funktion = {
|
|||||||
handy: { name: string; alt: string };
|
handy: { name: string; alt: string };
|
||||||
/** Der lange Screenshot, der sich im Handy-Rahmen nach oben schiebt. */
|
/** Der lange Screenshot, der sich im Handy-Rahmen nach oben schiebt. */
|
||||||
lang?: boolean;
|
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;
|
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 in der Safari-Leiste (reservierte Endung .example, es ist kein echter Betrieb gemeint). */
|
||||||
adresse?: string;
|
adresse?: string;
|
||||||
/** Beschreibung der Beispiel-Website für Screenreader (die Website selbst ist aria-hidden). */
|
/** Beschreibung der Beispiel-Website für Screenreader (die Website selbst ist aria-hidden). */
|
||||||
beschreibung?: string;
|
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;
|
hinweis?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,7 +108,12 @@ const FUNKTIONEN: Funktion[] = [
|
|||||||
id: "live-vorschau",
|
id: "live-vorschau",
|
||||||
titel: "Live-Vorschau",
|
titel: "Live-Vorschau",
|
||||||
unterschrift: "Ihre Website, noch während wir daran bauen.",
|
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" },
|
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",
|
id: "chat",
|
||||||
titel: "Chat",
|
titel: "Chat",
|
||||||
unterschrift: "Frage, Foto oder Sprachnachricht direkt ans Team.",
|
unterschrift: "Frage, Foto oder Sprachnachricht direkt ans Team.",
|
||||||
desktop: { name: "desktop-3b-chat-foto", alt: "Kundenportal: Chat mit dem Team, im Verlauf ein Foto" },
|
live: "chat",
|
||||||
handy: { name: "phone-4b-chat-foto", alt: "Kundenportal auf dem Handy: Chat mit Foto" },
|
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",
|
id: "rechnungen",
|
||||||
@@ -166,9 +199,16 @@ const FUNKTIONEN: Funktion[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const LETZTER = FUNKTIONEN.length - 1;
|
const LETZTER = FUNKTIONEN.length - 1;
|
||||||
/** Stationen vor "Als App": normale Schritte und längere Demo-Schritte (bestimmen die Scrollstrecke der Hülle). */
|
/** Art der Station: bestimmt, was nachgeladen wird und welche Hinweise unter dem MacBook stehen. */
|
||||||
const ANZAHL_DEMO = FUNKTIONEN.filter((f) => f.demo).length;
|
const ladeArt = (f: Funktion): LadeArt | undefined => f.demo ?? f.live;
|
||||||
const ANZAHL_NORMAL = LETZTER - ANZAHL_DEMO;
|
|
||||||
|
/**
|
||||||
|
* 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
|
/** Ü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. */
|
* der drei erreicht ist; die Unterpunkte zeigen nur ihren Titel. */
|
||||||
const GRUPPE = { titel: "Funktionen", text: "Bausteine für Ihre Website." };
|
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(() => {}) });
|
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 TerminSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.TerminSteuerung })));
|
||||||
const HeatmapSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.HeatmapSteuerung })));
|
const HeatmapSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.HeatmapSteuerung })));
|
||||||
const PinnwandSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.PinnwandSteuerung })));
|
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"));
|
const PinnwandHandy = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandHandy"));
|
||||||
|
|
||||||
/** Chunks je Demo; die Website-Chunks (Seite, Diagnose) und der Taktgeber laden gleichzeitig. */
|
/** 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],
|
termin: [TerminSteuerung.vorladen, TerminSeite.vorladen, TerminHandy.vorladen],
|
||||||
heatmap: [HeatmapSteuerung.vorladen, HeatmapSeite.vorladen, HeatmapDiagnose.vorladen],
|
heatmap: [HeatmapSteuerung.vorladen, HeatmapSeite.vorladen, HeatmapDiagnose.vorladen],
|
||||||
pinnwand: [PinnwandSteuerung.vorladen, PinnwandSeite.vorladen, PinnwandHandy.vorladen],
|
pinnwand: [PinnwandSteuerung.vorladen, PinnwandSeite.vorladen, PinnwandHandy.vorladen],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Hintergrund der Beispiel-Websites, solange ihr Chunk lädt (statt einer schwarzen Fläche im MacBook). */
|
/** 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: Record<LadeArt, string> = {
|
||||||
const Platzhalter = ({ art }: { art: DemoArt }) => (
|
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" />
|
<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)
|
// 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 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). */
|
/** Satz unter dem Titel einer Karte der Wisch-Reihe; die drei Funktionen haben keinen (nur der Titel). */
|
||||||
const KartenUnterschrift = ({ f }: { f: Funktion }) =>
|
const KartenUnterschrift = ({ f }: { f: Funktion }) =>
|
||||||
@@ -266,6 +319,10 @@ const Buehne = () => {
|
|||||||
const lauf = useDemoLauf(true, imBild);
|
const lauf = useDemoLauf(true, imBild);
|
||||||
const fein = useFeinerZeiger();
|
const fein = useFeinerZeiger();
|
||||||
const aktivDemo = FUNKTIONEN[aktiv].demo;
|
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 [termin, setTermin] = useState<TerminZustand | null>(null);
|
||||||
const [heat, setHeat] = useState<HeatmapZustand | null>(null);
|
const [heat, setHeat] = useState<HeatmapZustand | null>(null);
|
||||||
const [pinnwand, setPinnwand] = useState<PinnwandZustand | null>(null);
|
const [pinnwand, setPinnwand] = useState<PinnwandZustand | null>(null);
|
||||||
@@ -301,6 +358,7 @@ const Buehne = () => {
|
|||||||
raf = 0;
|
raf = 0;
|
||||||
if (!ruhig.matches) {
|
if (!ruhig.matches) {
|
||||||
el.style.setProperty("--kp-q", "0");
|
el.style.setProperty("--kp-q", "0");
|
||||||
|
el.style.setProperty("--kp-v", "0");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const vh = window.innerHeight;
|
const vh = window.innerHeight;
|
||||||
@@ -315,6 +373,15 @@ const Buehne = () => {
|
|||||||
const halten = 0.2 * vh; // am Ende bleibt der lange Screenshot kurz stehen
|
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);
|
const q = begrenze((seitSchritt6 - vorlauf) / Math.max(1, laenge - vorlauf - halten), 0, 1);
|
||||||
el.style.setProperty("--kp-q", q.toFixed(4));
|
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 = () => {
|
const planen = () => {
|
||||||
if (!raf) raf = requestAnimationFrame(berechne);
|
if (!raf) raf = requestAnimationFrame(berechne);
|
||||||
@@ -348,23 +415,60 @@ const Buehne = () => {
|
|||||||
|
|
||||||
const app = aktiv === LETZTER;
|
const app = aktiv === LETZTER;
|
||||||
const eingehaengt = (i: number) => i <= hoechster + 1;
|
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)
|
// Sobald die Station eingehängt ist, laden alle Chunks der Demo gleichzeitig (Website, Handy-Ebene, Taktgeber)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const art of Object.keys(VORLADEN) as DemoArt[]) {
|
for (const art of Object.keys(VORLADEN) as LadeArt[]) {
|
||||||
if (FUNKTIONEN.some((f, i) => f.demo === art && i <= hoechster + 1)) VORLADEN[art].forEach((vorladen) => vorladen());
|
if (FUNKTIONEN.some((f, i) => ladeArt(f) === art && i <= hoechster + 1)) VORLADEN[art].forEach((vorladen) => vorladen());
|
||||||
}
|
}
|
||||||
}, [hoechster]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div className="kp-huelle" ref={huelle} style={{ height: HUELLE_HOEHE }}>
|
||||||
className="kp-huelle"
|
|
||||||
ref={huelle}
|
|
||||||
style={{ ["--kp-nn" as string]: ANZAHL_NORMAL, ["--kp-nd" as string]: ANZAHL_DEMO }}
|
|
||||||
>
|
|
||||||
{/* Taktgeber der Demos (rendern nichts): erst geladen, wenn die Bühne ihre Station fast erreicht */}
|
{/* Taktgeber der Demos (rendern nichts): erst geladen, wenn die Bühne ihre Station fast erreicht */}
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
{demoEingehaengt("chat") ? (
|
||||||
|
<ChatSteuerung laeuft={lauf.laeuft && aktivLive === "chat"} statisch={lauf.statisch} onZustand={setChat} />
|
||||||
|
) : null}
|
||||||
{demoEingehaengt("termin") ? (
|
{demoEingehaengt("termin") ? (
|
||||||
<TerminSteuerung laeuft={lauf.laeuft && aktivDemo === "termin"} statisch={lauf.statisch} onZustand={setTermin} />
|
<TerminSteuerung laeuft={lauf.laeuft && aktivDemo === "termin"} statisch={lauf.statisch} onZustand={setTermin} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -385,16 +489,17 @@ const Buehne = () => {
|
|||||||
{FUNKTIONEN.map((f, i) => (
|
{FUNKTIONEN.map((f, i) => (
|
||||||
<div
|
<div
|
||||||
key={f.id}
|
key={f.id}
|
||||||
className={`kp-marker${f.demo ? " kp-marker--demo" : ""}`}
|
className="kp-marker"
|
||||||
data-index={i}
|
data-index={i}
|
||||||
|
style={{ ["--kp-l" as string]: strecke(f) }}
|
||||||
ref={(el) => (marker.current[i] = el)}
|
ref={(el) => (marker.current[i] = el)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<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) => {
|
{FUNKTIONEN.map((f, i) => {
|
||||||
if (!f.demo) {
|
if (!f.demo) {
|
||||||
return (
|
return (
|
||||||
@@ -414,9 +519,9 @@ const Buehne = () => {
|
|||||||
}
|
}
|
||||||
// die drei Funktionen: ein Überpunkt, der aufklappt, sobald eine von ihnen erreicht ist
|
// die drei Funktionen: ein Überpunkt, der aufklappt, sobald eine von ihnen erreicht ist
|
||||||
if (i !== DEMO_INDIZES[0]) return null;
|
if (i !== DEMO_INDIZES[0]) return null;
|
||||||
const offen = aktivDemo !== undefined;
|
const offen = gruppe === "offen";
|
||||||
return (
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="kp-eintrag kp-eintrag--gruppe"
|
className="kp-eintrag kp-eintrag--gruppe"
|
||||||
@@ -424,10 +529,21 @@ const Buehne = () => {
|
|||||||
aria-controls="kp-unterliste"
|
aria-controls="kp-unterliste"
|
||||||
onClick={() => springe(DEMO_INDIZES[0])}
|
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>
|
<span className="kp-eintrag__text">{GRUPPE.text}</span>
|
||||||
</button>
|
</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) => (
|
{DEMO_INDIZES.map((d) => (
|
||||||
<li key={FUNKTIONEN[d].id}>
|
<li key={FUNKTIONEN[d].id}>
|
||||||
<button
|
<button
|
||||||
@@ -451,7 +567,8 @@ const Buehne = () => {
|
|||||||
<DesktopFenster className="kp-fenster" adresse={FUNKTIONEN[aktiv].adresse}>
|
<DesktopFenster className="kp-fenster" adresse={FUNKTIONEN[aktiv].adresse}>
|
||||||
<div className="kp-ebenen" aria-hidden={app || undefined}>
|
<div className="kp-ebenen" aria-hidden={app || undefined}>
|
||||||
{FUNKTIONEN.map((f, i) => {
|
{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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={f.id}
|
key={f.id}
|
||||||
@@ -463,9 +580,11 @@ const Buehne = () => {
|
|||||||
>
|
>
|
||||||
{f.desktop ? (
|
{f.desktop ? (
|
||||||
<RahmenBild bild={desktopBild(f.desktop.name, f.desktop.alt)} />
|
<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)
|
// 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 === "termin" ? <TerminSeite zustand={termin ?? undefined} variante="desktop" /> : null}
|
||||||
{f.demo === "heatmap" ? <HeatmapSeite zustand={heat ?? undefined} variante="desktop" /> : null}
|
{f.demo === "heatmap" ? <HeatmapSeite zustand={heat ?? undefined} variante="desktop" /> : null}
|
||||||
{f.demo === "pinnwand" ? <PinnwandSeite zustand={pinnwand ?? undefined} variante="desktop" /> : null}
|
{f.demo === "pinnwand" ? <PinnwandSeite zustand={pinnwand ?? undefined} variante="desktop" /> : null}
|
||||||
@@ -494,6 +613,7 @@ const Buehne = () => {
|
|||||||
)}
|
)}
|
||||||
{f.demo ? (
|
{f.demo ? (
|
||||||
// Live-Ebene über dem Portal-Bild: Mitteilung bzw. Tipp-Finger, synchron zur Website
|
// 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}>
|
<Suspense fallback={null}>
|
||||||
{f.demo === "termin" && termin ? <TerminHandy zustand={termin} /> : null}
|
{f.demo === "termin" && termin ? <TerminHandy zustand={termin} /> : null}
|
||||||
{f.demo === "pinnwand" && pinnwand ? <PinnwandHandy zustand={pinnwand} punkt={AUFHAENGEN_PUNKT} /> : null}
|
{f.demo === "pinnwand" && pinnwand ? <PinnwandHandy zustand={pinnwand} punkt={AUFHAENGEN_PUNKT} /> : null}
|
||||||
@@ -504,16 +624,16 @@ const Buehne = () => {
|
|||||||
)}
|
)}
|
||||||
</HandyRahmen>
|
</HandyRahmen>
|
||||||
|
|
||||||
{/* unter dem MacBook, links neben dem iPhone: Hinweis auf das erfundene Beispiel und je Demo eine kleine Karte
|
{/* unter dem MacBook, links neben dem iPhone: Hinweis auf das erfundene Beispiel und je Live-Station eine kleine Karte
|
||||||
(Website-Analyse: die Diagnose, Terminplaner und Pinnwand: ein Hinweis zur Funktion) */}
|
(Website-Analyse: die Diagnose, sonst ein Hinweis zur Funktion) */}
|
||||||
<div className="kp-unter">
|
<div className="kp-unter">
|
||||||
<div className="kp-beispiele">
|
<div className="kp-beispiele">
|
||||||
{FUNKTIONEN.filter((f) => f.demo).map((f) => (
|
{FUNKTIONEN.filter((f) => ladeArt(f)).map((f) => (
|
||||||
<p
|
<p
|
||||||
key={f.id}
|
key={f.id}
|
||||||
className="kp-beispiel"
|
className="kp-beispiel"
|
||||||
data-an={f.demo === aktivDemo ? "ja" : "nein"}
|
data-an={ladeArt(f) === aktivArt ? "ja" : "nein"}
|
||||||
aria-hidden={f.demo !== aktivDemo || undefined}
|
aria-hidden={ladeArt(f) !== aktivArt || undefined}
|
||||||
>
|
>
|
||||||
{/* Website-Analyse: statt der Adresse (steht in der Safari-Leiste) der Bedienhinweis, damit es eine Zeile bleibt;
|
{/* 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“ */}
|
ohne Maus (Tablet quer) heißt es „Tippen Sie“ statt „Zeigen Sie mit der Maus“ */}
|
||||||
@@ -536,12 +656,12 @@ const Buehne = () => {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{FUNKTIONEN.filter((f) => f.demo && f.hinweis).map((f) => (
|
{FUNKTIONEN.filter((f) => ladeArt(f) && f.hinweis).map((f) => (
|
||||||
<p
|
<p
|
||||||
key={f.id}
|
key={f.id}
|
||||||
className="kp-hinweis"
|
className="kp-hinweis"
|
||||||
data-an={f.demo === aktivDemo ? "ja" : "nein"}
|
data-an={ladeArt(f) === aktivArt ? "ja" : "nein"}
|
||||||
aria-hidden={f.demo !== aktivDemo || undefined}
|
aria-hidden={ladeArt(f) !== aktivArt || undefined}
|
||||||
>
|
>
|
||||||
{f.hinweis}
|
{f.hinweis}
|
||||||
</p>
|
</p>
|
||||||
@@ -588,9 +708,12 @@ const DemoKarteRahmen = ({
|
|||||||
f,
|
f,
|
||||||
geraetRef,
|
geraetRef,
|
||||||
nachher,
|
nachher,
|
||||||
|
kulisse,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
f: Funktion;
|
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) */
|
/** Das Handy der Karte: nach seiner Sichtbarkeit richtet sich der Ablauf (die Karte selbst kann höher sein als der Bildschirm) */
|
||||||
geraetRef: RefObject<HTMLDivElement>;
|
geraetRef: RefObject<HTMLDivElement>;
|
||||||
/** Unter dem Text (Heatmap: die Diagnose) */
|
/** 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 kp-karte--demo" data-demo={f.demo}>
|
||||||
<div className="kp-karte__geraet" ref={geraetRef}>
|
<div className="kp-karte__geraet" ref={geraetRef}>
|
||||||
<HandyRahmen className="kp-karte__handy">
|
<HandyRahmen className="kp-karte__handy">
|
||||||
|
{kulisse}
|
||||||
<Suspense fallback={f.demo ? <Platzhalter art={f.demo} /> : null}>{children}</Suspense>
|
<Suspense fallback={f.demo ? <Platzhalter art={f.demo} /> : null}>{children}</Suspense>
|
||||||
</HandyRahmen>
|
</HandyRahmen>
|
||||||
</div>
|
</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:
|
* 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.
|
* 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 ref = useRef<HTMLDivElement>(null);
|
||||||
const nah = useImBild(ref, 0);
|
const nah = useImBild(ref, 0);
|
||||||
const imBild = useImBild(ref, 0.6);
|
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 PinnwandKarte = ({ f }: { f: Funktion }) => {
|
||||||
const k = useKarte("pinnwand");
|
const k = useKarte("pinnwand");
|
||||||
const [z, setZ] = useState<PinnwandZustand | null>(null);
|
const [z, setZ] = useState<PinnwandZustand | null>(null);
|
||||||
@@ -679,6 +819,7 @@ const HandyReihe = () => (
|
|||||||
<WischReihe ariaLabel="Acht Funktionen des Kundenportals, seitlich wischen">
|
<WischReihe ariaLabel="Acht Funktionen des Kundenportals, seitlich wischen">
|
||||||
{FUNKTIONEN.map((f) => {
|
{FUNKTIONEN.map((f) => {
|
||||||
if (f.lang) return <LangeKarte key={f.id} f={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 === "termin") return <TerminKarte key={f.id} f={f} />;
|
||||||
if (f.demo === "heatmap") return <HeatmapKarte 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} />;
|
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 { useTerminAblauf, type TerminZustand } from "./termin/ablauf";
|
||||||
import { useHeatmapAblauf, type HeatmapZustand } from "./heatmap/ablauf";
|
import { useHeatmapAblauf, type HeatmapZustand } from "./heatmap/ablauf";
|
||||||
import { usePinnwandAblauf, type PinnwandZustand } from "./pinnwand/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
|
* 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
|
* 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.
|
* Zustand an Website, Handy-Ebene und Diagnose.
|
||||||
@@ -26,6 +27,12 @@ export const PinnwandSteuerung = memo(function PinnwandSteuerung({ laeuft, stati
|
|||||||
return null;
|
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. */
|
/** `aktiv`: Station ist die aktive; sonst bleiben weder Maus-Zustand noch Auswahl der Heatmap hängen. */
|
||||||
export const HeatmapSteuerung = memo(function HeatmapSteuerung({
|
export const HeatmapSteuerung = memo(function HeatmapSteuerung({
|
||||||
laeuft,
|
laeuft,
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
.kp {
|
.kp {
|
||||||
/* Länge eines Funktionsschritts auf der Bühne; die normalen und die längeren Demo-Schritte (der Ablauf soll zu sehen
|
/* Länge eines Schritts auf der Bühne: normale Stationen, die Demos (der Ablauf soll zu sehen sein), die Live-Vorschau
|
||||||
sein) plus der letzte Schritt ergeben die Scrollstrecke */
|
(die Website läuft beim Scrollen durch) und der Chat. Mit dem letzten Schritt ergeben sie die Scrollstrecke der Hülle
|
||||||
--kp-s: 45svh;
|
(Summe setzt die Bühne inline, siehe HUELLE_HOEHE). */
|
||||||
--kp-d: 70svh;
|
--kp-l-normal: 45svh;
|
||||||
|
--kp-l-demo: 70svh;
|
||||||
|
--kp-l-vorschau: 110svh;
|
||||||
|
--kp-l-chat: 90svh;
|
||||||
--kp-ende: 120svh;
|
--kp-ende: 120svh;
|
||||||
--kp-oben: calc(var(--header-h) + 24px);
|
--kp-oben: calc(var(--header-h) + 24px);
|
||||||
--kp-buehne-h: calc(100svh - var(--header-h) - 48px);
|
--kp-buehne-h: calc(100svh - var(--header-h) - 48px);
|
||||||
@@ -18,8 +21,10 @@
|
|||||||
.kp {
|
.kp {
|
||||||
/* nichts bewegt sich im letzten Schritt, also braucht er keine lange Strecke */
|
/* nichts bewegt sich im letzten Schritt, also braucht er keine lange Strecke */
|
||||||
--kp-ende: 50svh;
|
--kp-ende: 50svh;
|
||||||
/* die Demos zeigen ihren Endzustand und brauchen keine lange Strecke */
|
/* die Demos zeigen ihren Endzustand, die Website steht still: keine lange Strecke nötig */
|
||||||
--kp-d: 45svh;
|
--kp-l-demo: 45svh;
|
||||||
|
--kp-l-vorschau: 45svh;
|
||||||
|
--kp-l-chat: 45svh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,10 +42,7 @@
|
|||||||
|
|
||||||
.kp-huelle {
|
.kp-huelle {
|
||||||
position: relative;
|
position: relative;
|
||||||
/* --kp-nn und --kp-nd (Zahl der normalen und der Demo-Schritte vor "Als App") setzt die Bühne inline */
|
/* Höhe (Bühne + alle Schritte + letzter Schritt) setzt die Bühne inline aus den Schrittlängen */
|
||||||
height: calc(
|
|
||||||
var(--kp-buehne-h) + var(--kp-nn, 4) * var(--kp-s) + var(--kp-nd, 3) * var(--kp-d) + var(--kp-ende)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* unsichtbare Spur aus Markern: der IntersectionObserver beobachtet sie, die Bühne klebt darüber */
|
/* unsichtbare Spur aus Markern: der IntersectionObserver beobachtet sie, die Bühne klebt darüber */
|
||||||
@@ -51,15 +53,13 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
/* --kp-l (Länge des Schritts) setzt die Bühne je Marke inline */
|
||||||
.kp-marker {
|
.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 */
|
/* 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 {
|
.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 {
|
.kp-marker:last-child {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -184,12 +184,36 @@
|
|||||||
.kp-gruppe[data-offen="ja"] .kp-eintrag--gruppe .kp-eintrag__titel {
|
.kp-gruppe[data-offen="ja"] .kp-eintrag--gruppe .kp-eintrag__titel {
|
||||||
color: var(--fg);
|
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 {
|
.kp-unterliste {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin: 2px 0 4px;
|
margin: 2px 0 4px;
|
||||||
padding-left: 14px;
|
padding-left: 15px;
|
||||||
border-left: 1px solid var(--line-strong);
|
}
|
||||||
|
/* 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] {
|
.kp-unterliste[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -229,26 +253,63 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@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 {
|
.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) {
|
.kp-unterliste:not([hidden]) > li:nth-child(2) {
|
||||||
animation-delay: 60ms;
|
animation-delay: 50ms;
|
||||||
}
|
}
|
||||||
.kp-unterliste:not([hidden]) > li:nth-child(3) {
|
.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 {
|
@keyframes kp-aufklappen {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-6px);
|
transform: translateY(-10px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: none;
|
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 */
|
/* Geräte: Desktop-Fenster mit überlappendem Handy-Rahmen unten rechts */
|
||||||
.kp-geraete {
|
.kp-geraete {
|
||||||
@@ -378,6 +439,12 @@
|
|||||||
.kp-buehne[data-app="ja"] .kp-handy {
|
.kp-buehne[data-app="ja"] .kp-handy {
|
||||||
transform: translate(-35.9cqw, -14.2cqw) scale(1.5);
|
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
|
/* 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. */
|
(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);
|
transition: opacity var(--dur-enter) var(--ease);
|
||||||
}
|
}
|
||||||
.kp-handy {
|
.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 {
|
.kp-buehne .kp-lang {
|
||||||
transform: translateY(calc(var(--kp-q, 0) * -65.93%));
|
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 Kundenportal from "@/components/home/Kundenportal";
|
||||||
import GeraeteRahmen, { DesktopFenster, HandyRahmen } from "@/components/GeraeteRahmen";
|
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 WURZEL = `${process.cwd()}/`;
|
||||||
const BILDORDNER = join(WURZEL, "public/relaunch/portal");
|
const BILDORDNER = join(WURZEL, "public/relaunch/portal");
|
||||||
@@ -29,11 +30,15 @@ const SCHRITTE = [
|
|||||||
"Online – Hosting und Pflege auf Wunsch im Abo",
|
"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. */
|
/** 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) => {
|
* "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;
|
const vorher = window.matchMedia;
|
||||||
window.matchMedia = ((query: string) => ({
|
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,
|
media: query,
|
||||||
onchange: null,
|
onchange: null,
|
||||||
addListener: () => {},
|
addListener: () => {},
|
||||||
@@ -56,6 +61,11 @@ const zeigeDesktop = () => {
|
|||||||
zuruecksetzen = mitBreite(true);
|
zuruecksetzen = mitBreite(true);
|
||||||
return render(<Kundenportal />);
|
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). */
|
/** Bühne ab 900 px, aber ohne Maus (Tablet quer). */
|
||||||
const zeigeTablet = () => {
|
const zeigeTablet = () => {
|
||||||
zuruecksetzen = mitBreite(true, false);
|
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“", () => {
|
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 { 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).toHaveTextContent("Tippen Sie auf eine Stelle der Seite.");
|
||||||
expect(hinweis).not.toHaveTextContent("Maus");
|
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)", () => {
|
it("Desktop: zu Beginn hängen nur die ersten beiden Schritte ihre Bilder ein (spart Daten)", () => {
|
||||||
const { container } = zeigeDesktop();
|
const { container } = zeigeDesktop();
|
||||||
const bilder = Array.from(container.querySelectorAll("img"));
|
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([
|
expect(bilder.map((b) => b.getAttribute("src"))).toEqual([
|
||||||
"/relaunch/portal/desktop-1-projekte-1200.webp",
|
|
||||||
"/relaunch/portal/desktop-2-fortschritt-1200.webp",
|
"/relaunch/portal/desktop-2-fortschritt-1200.webp",
|
||||||
"/relaunch/portal/phone-1-projekte-780.webp",
|
"/relaunch/portal/phone-1-projekte-780.webp",
|
||||||
"/relaunch/portal/phone-3-projektdetails-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)", () => {
|
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");
|
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 [
|
for (const nicht of [
|
||||||
|
"desktop-1-projekte",
|
||||||
|
"desktop-3b-chat-foto",
|
||||||
|
"phone-4b-chat-foto",
|
||||||
"desktop-5-stundenkonto",
|
"desktop-5-stundenkonto",
|
||||||
"desktop-6-funktionen",
|
"desktop-6-funktionen",
|
||||||
"phone-6-vertraege",
|
"phone-6-vertraege",
|
||||||
@@ -218,9 +231,10 @@ describe("Kundenportal: Bilder (Spezifikation 9.5)", () => {
|
|||||||
expect(skript, nicht).not.toContain(nicht);
|
expect(skript, nicht).not.toContain(nicht);
|
||||||
expect(readdirSync(BILDORDNER).filter((d) => d.startsWith(nicht)), nicht).toEqual([]);
|
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)) {
|
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
|
// der lange Screenshot ist jetzt eingehängt und aktiv
|
||||||
const lang = container.querySelector(".kp-handy .kp-lage.is-aktiv .kp-lang img");
|
const lang = container.querySelector(".kp-handy .kp-lage.is-aktiv .kp-lang img");
|
||||||
expect(lang?.getAttribute("src")).toContain("phone-2-projekte-lang");
|
expect(lang?.getAttribute("src")).toContain("phone-2-projekte-lang");
|
||||||
// alle früheren Bilder sind inzwischen eingehängt
|
// alle früheren Bilder sind inzwischen eingehängt: zwei Desktop-Aufnahmen (Fortschritt, Rechnungen) und acht Handy-Aufnahmen
|
||||||
expect(container.querySelectorAll("img").length).toBe(4 + 8);
|
// (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 () => {
|
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();
|
beobachterAttrappe();
|
||||||
const { container } = zeigeDesktop();
|
const { container } = zeigeDesktop();
|
||||||
const adresse = () => container.querySelector(".dm-macbook__url")?.textContent;
|
const adresse = () => container.querySelector(".dm-macbook__url")?.textContent;
|
||||||
|
expect(adresse()).toBe("baeckerei-sonnenschein.project.webklar.com");
|
||||||
|
meldeMarke(1);
|
||||||
expect(adresse()).toBe("project.webklar.com");
|
expect(adresse()).toBe("project.webklar.com");
|
||||||
meldeMarke(4);
|
meldeMarke(4);
|
||||||
expect(adresse()).toBe("salon-schnittstelle.example");
|
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
|
// 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"));
|
const hinweise = Array.from(container.querySelectorAll(".kp-beispiel"));
|
||||||
expect(hinweise.map((h) => h.textContent)).toEqual([
|
expect(hinweise.map((h) => h.textContent)).toEqual([
|
||||||
|
"Erfundenes Beispiel · Bäckerei Sonnenschein",
|
||||||
|
"Erfundenes Beispiel · Bäckerei Sonnenschein im Kundenportal",
|
||||||
"Erfundenes Beispiel · salon-schnittstelle.example",
|
"Erfundenes Beispiel · salon-schnittstelle.example",
|
||||||
// mit Maus (diese Bühne meldet eine feine Zeigereingabe): Hinweis zum Zeigen
|
// mit Maus (diese Bühne meldet eine feine Zeigereingabe): Hinweis zum Zeigen
|
||||||
"Erfundenes Beispiel · Zeigen Sie mit der Maus auf die Seite.",
|
"Erfundenes Beispiel · Zeigen Sie mit der Maus auf die Seite.",
|
||||||
"Erfundenes Beispiel · musterbach.example",
|
"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);
|
meldeMarke(6);
|
||||||
expect(diagnose()).toHaveAttribute("data-an", "nein");
|
expect(diagnose()).toHaveAttribute("data-an", "nein");
|
||||||
expect(diagnose()).toHaveAttribute("inert");
|
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();
|
beobachterAttrappe();
|
||||||
const { container } = zeigeDesktop();
|
const { container } = zeigeDesktop();
|
||||||
const hinweise = () => Array.from(container.querySelectorAll(".kp-hinweis")) as HTMLElement[];
|
const hinweise = () => Array.from(container.querySelectorAll(".kp-hinweis")) as HTMLElement[];
|
||||||
expect(hinweise().map((h) => h.textContent)).toEqual([
|
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.",
|
"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.",
|
"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);
|
meldeMarke(4);
|
||||||
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["ja", "nein"]);
|
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "ja", "nein"]);
|
||||||
expect(hinweise().map((h) => h.hasAttribute("aria-hidden"))).toEqual([false, true]);
|
expect(hinweise().map((h) => h.hasAttribute("aria-hidden"))).toEqual([true, true, false, true]);
|
||||||
meldeMarke(5);
|
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);
|
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 () => {
|
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 () => {
|
it("Handy-Karten: die drei Websites laden erst, wenn die Karte in Sicht kommt; Tippen auf die Heatmap wählt eine Zone", async () => {
|
||||||
beobachterAttrappe();
|
beobachterAttrappe();
|
||||||
const { container } = zeigeHandy();
|
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);
|
expect(karten).toHaveLength(3);
|
||||||
// noch nichts gesehen: Rahmen leer, nichts geladen
|
// noch nichts gesehen: Rahmen leer, nichts geladen
|
||||||
expect(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite)")).toHaveLength(0);
|
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");
|
expect(heat.querySelector(".hmd")).toHaveAttribute("data-auswahl", "routenbild");
|
||||||
// Hinweis aufs erfundene Beispiel an jeder Demo-Karte
|
// Hinweis aufs erfundene Beispiel an jeder Demo-Karte
|
||||||
for (const karte of karten) expect(karte.querySelector(".kp-karte__beispiel")?.textContent).toMatch(/^Erfundenes Beispiel · .+\.example$/);
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||