Kundenansicht neu P2: Ansicht Chat mit dem Composer und lilafreier CSS-Kopie

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 16:59:28 +00:00
1 parent a3831040ba
commit 8c8d29657d
6 files changed
+475 -9

No files matched your search

+143 -4
View File
@@ -1,7 +1,146 @@
/* Kundenansicht neu: Platzhalter, den das nächste Paket füllt (Stub aus P1). */
/* Kundenansicht neu (P2): Bereich Chat. Verlauf und Eingabezeile des gemeinsamen Composers (window.ChatComposer, chat-composer.js),
Poll alle 10 s mit ?since=, Gelesen-Markierung, Vorgabe aus "Postfach anfragen". Nachrichten gehen nur als Text in den Verlauf. */
import { h } from './dom.js'
import { api } from './api.js'
import { h, leeren } from './dom.js'
import { K, fmtDatumZeit, meldeZaehlerNeu, nimmChatVorgabe } from './ansicht-hilfen.js'
export async function zeigeChat(wurzel, { route, me, nav }) {
wurzel.replaceChildren(h('p', { class: 'kn-leer' }, 'Chat (folgt)'))
export const CHAT_TEXTE = {
platzhalter: 'Nachricht an das WEBklar-Team…',
kurz: 'Nachricht…',
gesperrt: 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.',
leer: 'Noch keine Nachrichten.',
}
const POLL_MS = 10000
const dateiUrl = (att) => `/api/chat/attachments/${encodeURIComponent(att.id)}`
/** 'Du · 14.10.26, 10:05' oder '<Name oder Webklar-Team> · …'. */
export function nachrichtMeta(msg) {
const meine = msg?.sender === 'customer'
return `${meine ? 'Du' : msg?.senderName || 'Webklar-Team'} · ${fmtDatumZeit(msg?.createdAt)}`
}
let aktiv = null // der laufende Chat: { timer, composer }
function raeumeAuf() {
if (!aktiv) return
if (aktiv.timer) clearInterval(aktiv.timer)
try { aktiv.composer?.destroy?.() } catch { /* schon abgebaut */ }
aktiv = null
}
export async function zeigeChat(wurzel, { route, me, nav } = {}) {
raeumeAuf()
const eigener = { timer: null, composer: null }
aktiv = eigener
const gilt = () => aktiv === eigener
leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …'))
const Composer = globalThis.ChatComposer
if (!Composer) {
leeren(wurzel).append(h('p', { class: K.fehler }, 'Der Chat konnte nicht gestartet werden. Bitte lade die Seite neu.'))
return
}
const viewAs = me?.viewAs === true
const verlauf = h('div', { class: 'chat-messages kn-chat-verlauf', role: 'log', 'aria-label': 'Nachrichtenverlauf' })
const composerWurzel = h('div', { class: 'kn-chat-composer' })
const kasten = h('div', { class: 'chat-box kn-chat-kasten' }, verlauf, composerWurzel)
leeren(wurzel).append(h('div', { class: 'kn-chat' }, kasten))
const gezeigt = new Set()
let zuletzt = ''
let leerHinweis = null
let fehler = null
const hinweisLeer = () => {
if (gezeigt.size || leerHinweis) return
leerHinweis = h('p', { class: K.leer }, CHAT_TEXTE.leer)
verlauf.append(leerHinweis)
}
const raeumeHinweise = () => { leerHinweis?.remove(); leerHinweis = null; fehler?.remove(); fehler = null }
/** Neue Nachrichten anhängen (nach id entdoppelt); Rückgabe: die wirklich neuen. */
function haengeAn(nachrichten) {
const neu = []
for (const msg of nachrichten) {
if (!msg || gezeigt.has(msg.id)) continue
gezeigt.add(msg.id)
neu.push(msg)
}
if (!neu.length) return neu
raeumeHinweise()
for (const msg of neu) {
verlauf.append(Composer.renderMessage(msg, { mine: msg.sender === 'customer', meta: nachrichtMeta(msg), fileUrl: dateiUrl }))
if (msg.createdAt > zuletzt) zuletzt = msg.createdAt
}
verlauf.scrollTop = verlauf.scrollHeight
return neu
}
// Nachrichten als gelesen markieren; im Ansichtsmodus nie (der Kunde hat sie nicht gelesen)
async function markiereGelesen() {
if (viewAs) return
await api('/api/chat/read', { method: 'POST' }).catch(() => {})
if (gilt()) meldeZaehlerNeu('chat')
}
async function hole() {
const frage = zuletzt ? `?since=${encodeURIComponent(zuletzt)}` : ''
const daten = await api(`/api/chat/messages${frage}`)
if (!gilt()) return []
return haengeAn(Array.isArray(daten?.messages) ? daten.messages : [])
}
async function poll() {
if (!gilt()) return
if (!wurzel.isConnected) { clearInterval(eigener.timer); return }
if (globalThis.document?.hidden) return
if (wurzel.closest?.('[hidden]')) return // der Chat ist gerade nicht zu sehen
try {
const neu = await hole()
if (neu.some((m) => m.sender !== 'customer')) await markiereGelesen()
} catch { /* der nächste Poll versucht es erneut */ }
}
Composer.trackScroll(verlauf)
eigener.composer = Composer.create({
root: composerWurzel,
dropZone: kasten,
uploadUrl: '/api/chat/attachments',
placeholder: CHAT_TEXTE.platzhalter,
placeholderShort: CHAT_TEXTE.kurz,
disabled: viewAs,
disabledText: CHAT_TEXTE.gesperrt,
send: async ({ text, attachmentIds }) => {
const { message } = await api('/api/chat/messages', { method: 'POST', body: { text, attachmentIds } })
return message
},
onSent: (message) => { haengeAn([message]) },
})
const vorgabe = nimmChatVorgabe()
if (vorgabe) {
const feld = composerWurzel.querySelector('textarea')
if (feld) {
feld.value = vorgabe
if (typeof Event === 'function') feld.dispatchEvent(new Event('input', { bubbles: true }))
feld.focus()
if (typeof feld.setSelectionRange === 'function') feld.setSelectionRange(vorgabe.length, vorgabe.length)
}
}
try {
await hole()
if (!gilt()) return
hinweisLeer()
} catch (f) {
if (!gilt()) return
fehler = h('p', { class: K.fehler }, `Nachrichten konnten nicht geladen werden: ${f.message}`)
verlauf.append(fehler)
}
await markiereGelesen()
if (!gilt()) return
eigener.timer = setInterval(poll, POLL_MS)
eigener.timer?.unref?.() // im Browser eine Zahl; in Node hält der Timer den Prozess nicht fest
}
+242
View File
@@ -0,0 +1,242 @@
/* Chat: Anhaenge, Kamera und Sprachnachrichten fuer die weisse Kundenansicht (Kopie von chat-composer.css, siehe chat-composer.js).
Gegenueber dem Original: helle, neutrale Variablen, kein Lila, kein Gewicht 800 und kein Weichzeichner im Overlay. Das Original bleibt fuer die alte Ansicht. */
:root {
--chat-accent: #111111;
--chat-accent-hover: #333333;
--chat-accent-soft: rgba(17, 17, 17, 0.08);
--chat-accent-line: rgba(17, 17, 17, 0.5);
--chat-accent-text: #111111;
--chat-on-accent: #ffffff;
--chat-line: #d8dde3;
--chat-muted: #5e6678;
--chat-surface: #ffffff;
}
.chat-composer [hidden], .chat-overlay [hidden], .chat-bubble [hidden] { display: none !important; }
.chat-composer button, .chat-bubble button, .chat-bubble a, .chat-overlay button { -webkit-tap-highlight-color: transparent; }
.chat-ico { display: inline-grid; place-items: center; line-height: 0; }
.chat-ico svg { width: 20px; height: 20px; }
/* ------------------------------------------------------------- Verlauf --- */
.chat-bubble { white-space: normal; }
.chat-bubble .chat-text { white-space: pre-wrap; }
.chat-bubble.has-atts { padding: 6px 6px 8px; }
.chat-bubble.has-atts .chat-text, .chat-bubble.has-atts .chat-meta { padding: 0 8px; }
.chat-atts { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; }
.chat-atts + .chat-text { margin-top: 8px; }
.chat-att-images { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; width: 272px; max-width: 100%; }
.chat-att-images.single { display: block; width: auto; }
.chat-att-image {
position: relative; display: block; max-width: 100%; padding: 0; border: 0; border-radius: 12px; overflow: hidden;
background: rgba(255, 255, 255, 0.06); color: inherit; font: inherit; cursor: zoom-in;
}
.chat-att-image img { display: block; }
.chat-att-images.single img { max-width: 100%; max-height: 320px; height: auto; }
.chat-att-images:not(.single) .chat-att-image { aspect-ratio: 1; }
.chat-att-images:not(.single) img { width: 100%; height: 100%; object-fit: cover; }
.chat-att-image:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.chat-att-image.is-broken {
display: flex; align-items: center; gap: 8px; padding: 12px 14px; aspect-ratio: auto;
font-size: 13px; color: var(--chat-muted); cursor: default;
}
.chat-att-file {
display: flex; align-items: center; gap: 10px; width: 270px; max-width: 100%; box-sizing: border-box; padding: 8px 12px 8px 8px;
border-radius: 12px; background: rgba(0, 0, 0, 0.22); border: 1px solid rgba(255, 255, 255, 0.12);
color: inherit !important; text-decoration: none !important;
}
.chat-att-file:hover { background: rgba(0, 0, 0, 0.34); }
.chat-att-file > .chat-ico svg { width: 18px; height: 18px; opacity: 0.75; }
.chat-file-icon { position: relative; flex: none; width: 36px; height: 40px; display: grid; place-items: center; }
.chat-file-icon .chat-ico svg { width: 34px; height: 34px; stroke-width: 1.4; opacity: 0.9; }
.chat-file-ext {
position: absolute; left: 50%; bottom: 6px; transform: translateX(-50%); padding: 0 3px; border-radius: 3px;
font-size: 8.5px; line-height: 13px; font-weight: 700; letter-spacing: 0.02em; background: var(--chat-accent); color: var(--chat-on-accent);
white-space: nowrap; word-break: normal;
}
.chat-file-ext:empty { display: none; }
.chat-file-text { flex: 1; min-width: 0; display: grid; gap: 1px; }
.chat-file-name { font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-file-size { font-size: 12px; opacity: 0.7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-audio { display: flex; align-items: center; gap: 10px; width: 260px; max-width: 100%; box-sizing: border-box; padding: 2px; }
.chat-audio-play {
flex: none; width: 38px; height: 38px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center;
background: var(--chat-accent); color: var(--chat-on-accent); cursor: pointer;
}
.chat-bubble.mine .chat-audio-play { background: #fff; color: #17102a; }
.chat-audio-play .chat-ico svg { width: 16px; height: 16px; }
.chat-audio-main { flex: 1; min-width: 0; display: grid; gap: 7px; }
.chat-audio-label { font-size: 12px; font-weight: 600; opacity: 0.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-audio-bar { position: relative; display: block; height: 6px; border-radius: 999px; background: rgba(255, 255, 255, 0.2); cursor: pointer; }
.chat-audio-bar::after { content: ''; position: absolute; inset: -8px 0; }
.chat-audio-bar:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
.chat-audio-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; border-radius: inherit; background: currentColor; }
.chat-audio-time { flex: none; min-width: 32px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; opacity: 0.8; }
.chat-audio-dl {
flex: none; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%;
color: inherit !important; opacity: 0.7;
}
.chat-audio-dl:hover { opacity: 1; background: rgba(255, 255, 255, 0.1); }
.chat-audio-dl .chat-ico svg { width: 16px; height: 16px; }
.chat-audio.is-error .chat-audio-time { opacity: 1; color: #fca5a5; font-size: 11px; }
/* ------------------------------------------------------- Eingabezeile --- */
.chat-composer { position: relative; margin-top: 12px; }
.chat-composer .chat-input-row { display: flex; align-items: flex-end; gap: 8px; margin-top: 0; }
.chat-composer textarea { flex: 1; min-width: 0; box-sizing: border-box; }
.chat-icon-btn {
flex: none; width: 46px; height: 46px; padding: 0; border-radius: 50%; display: grid; place-items: center;
border: 1px solid var(--chat-line); background: rgba(255, 255, 255, 0.05); color: inherit; cursor: pointer;
}
.chat-icon-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.11); }
.chat-icon-btn:disabled { opacity: 0.45; cursor: default; }
.chat-icon-btn[aria-expanded="true"] { background: var(--chat-accent-soft); border-color: var(--chat-accent-line); }
.chat-icon-btn:focus-visible, .chat-send:focus-visible, .chat-audio-play:focus-visible, .chat-tray-remove:focus-visible {
outline: 2px solid var(--chat-accent-text); outline-offset: 2px;
}
.chat-attach { position: relative; flex: none; }
.chat-menu {
position: absolute; left: 0; bottom: calc(100% + 8px); z-index: 30; min-width: 190px; padding: 6px; display: grid; gap: 2px;
border-radius: 14px; background: var(--chat-surface); border: 1px solid var(--chat-line); box-shadow: 0 14px 36px rgba(0, 0, 0, 0.55);
}
.chat-menu button {
display: flex; align-items: center; gap: 12px; width: 100%; padding: 10px 12px; border: 0; border-radius: 10px;
background: none; color: inherit; font: inherit; font-size: 14px; text-align: left; cursor: pointer;
}
.chat-menu button:hover, .chat-menu button:focus-visible { background: rgba(255, 255, 255, 0.08); outline: none; }
.chat-menu .chat-ico { color: var(--chat-accent-text); }
.chat-send {
flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 46px; padding: 0 20px;
border: none; border-radius: 999px; background: var(--chat-accent); color: var(--chat-on-accent);
font: inherit; font-weight: 700; cursor: pointer;
}
.chat-send:hover:not(:disabled) { background: var(--chat-accent-hover); }
.chat-send:disabled { opacity: 0.5; cursor: default; }
.chat-send.is-busy:disabled { opacity: 0.8; cursor: progress; }
.chat-send .chat-ico { display: none; }
.chat-send .chat-ico svg { width: 19px; height: 19px; }
.chat-composer-note { margin: 8px 2px 0; font-size: 13px; line-height: 1.45; color: #fca5a5; }
.chat-composer-note.is-info { color: var(--chat-muted); }
/* Vorschau der Anhaenge vor dem Senden */
.chat-tray { display: flex; gap: 8px; padding: 2px 2px 10px; overflow-x: auto; scrollbar-width: thin; }
.chat-tray-item {
position: relative; flex: none; display: flex; align-items: center; gap: 10px; height: 64px; box-sizing: border-box;
border-radius: 12px; border: 1px solid var(--chat-line); background: rgba(255, 255, 255, 0.05); overflow: hidden;
}
.chat-tray-item.is-image { width: 64px; }
.chat-tray-item.is-image img { display: block; width: 100%; height: 100%; object-fit: cover; }
.chat-tray-item.is-image.is-queued img, .chat-tray-item.is-image.is-uploading img { opacity: 0.55; }
.chat-tray-item.is-file { width: 220px; padding: 0 36px 0 8px; }
.chat-tray-item.is-file.is-error { padding-right: 64px; }
.chat-tray-item.is-voice { width: 280px; padding: 0 36px 0 10px; }
.chat-tray-item.is-voice .chat-audio { width: 100%; padding: 0; }
.chat-tray-progress { position: absolute; left: 0; bottom: 0; height: 3px; width: 0; background: var(--chat-accent); transition: width 0.2s; }
.chat-tray-item.is-done .chat-tray-progress { opacity: 0; transition: opacity 0.6s 0.3s; }
.chat-tray-item.is-error { border-color: rgba(248, 113, 113, 0.75); background: rgba(248, 113, 113, 0.08); }
.chat-tray-item.is-error .chat-file-size { color: #fca5a5; opacity: 1; }
.chat-tray-remove, .chat-tray-retry {
position: absolute; top: 5px; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center;
background: rgba(0, 0, 0, 0.72); color: #fff; cursor: pointer;
}
.chat-tray-remove { right: 5px; }
.chat-tray-retry { right: 34px; }
.chat-tray-item.is-image .chat-tray-retry { top: 50%; left: 50%; right: auto; width: 30px; height: 30px; transform: translate(-50%, -50%); }
.chat-tray-remove .chat-ico svg, .chat-tray-retry .chat-ico svg { width: 14px; height: 14px; }
/* Sprachaufnahme (ersetzt waehrenddessen Textfeld und Anhang-Knopf) */
.chat-rec {
display: none; flex: 1; min-width: 0; align-items: center; gap: 10px; height: 46px; box-sizing: border-box; padding: 0 5px;
border-radius: 999px; border: 1px solid rgba(248, 113, 113, 0.45); background: rgba(248, 113, 113, 0.08);
}
.chat-composer.is-recording .chat-rec { display: flex; }
.chat-composer.is-recording .chat-attach,
.chat-composer.is-recording textarea,
.chat-composer.is-recording .chat-mic { display: none; }
.chat-rec .chat-icon-btn { width: 36px; height: 36px; border: 0; background: transparent; }
.chat-rec .chat-icon-btn:hover { background: rgba(255, 255, 255, 0.1); }
.chat-rec-stop { color: #f87171; }
.chat-rec-dot { flex: none; width: 10px; height: 10px; border-radius: 50%; background: #f87171; animation: chat-rec-pulse 1.2s ease-in-out infinite; }
.chat-rec-label { flex: none; font-size: 13px; color: var(--chat-muted); }
.chat-rec-time { flex: none; min-width: 36px; font-weight: 700; font-variant-numeric: tabular-nums; }
.chat-rec-wave { flex: 1; min-width: 0; height: 24px; display: flex; align-items: center; justify-content: flex-end; gap: 2px; overflow: hidden; color: #fca5a5; }
.chat-rec-wave i { flex: none; width: 3px; height: 100%; border-radius: 2px; background: currentColor; transform: scaleY(0.1); transition: transform 0.09s linear; }
@keyframes chat-rec-pulse { 50% { opacity: 0.25; } }
/* Dateien in den Chat ziehen */
.chat-dropzone { position: relative; }
.chat-drop-active::after {
content: 'Dateien hier ablegen'; position: absolute; inset: 0; z-index: 40; display: grid; place-items: center;
border: 2px dashed var(--chat-accent-line); border-radius: inherit; background: rgba(10, 10, 12, 0.85);
color: #fff; font-weight: 700; pointer-events: none;
}
/* ---------------------------------------------- Bildansicht und Kamera --- */
html.chat-overlay-open, html.chat-overlay-open body { overflow: hidden; }
.chat-overlay {
position: fixed; inset: 0; z-index: 1000; display: flex; flex-direction: column;
background: rgba(4, 4, 6, 0.95); color: #fff; font-family: inherit;
}
.chat-ov-top { flex: none; display: flex; align-items: center; gap: 10px; padding: 12px 14px; padding-top: max(12px, env(safe-area-inset-top)); }
.chat-ov-title { flex: 1; min-width: 0; font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-ov-btn {
flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 44px; height: 44px; padding: 0 12px;
box-sizing: border-box; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.25); background: rgba(255, 255, 255, 0.1);
color: #fff !important; font: inherit; font-size: 14px; font-weight: 600; text-decoration: none; cursor: pointer;
}
.chat-ov-btn:hover { background: rgba(255, 255, 255, 0.2); }
.chat-ov-btn:focus-visible, .chat-shutter:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
.chat-ov-btn.wide { padding: 0 18px; }
.chat-ov-btn.primary { background: var(--chat-accent); border-color: transparent; color: var(--chat-on-accent) !important; }
.chat-ov-btn.primary:hover { background: var(--chat-accent-hover); }
.chat-lightbox-stage { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 0 12px 16px; }
.chat-lightbox-stage img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); }
.chat-cam-stage { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 0 12px; }
.chat-cam-video, .chat-cam-still { max-width: 100%; max-height: 100%; border-radius: 14px; background: #000; }
.chat-cam-video.is-mirrored { transform: scaleX(-1); }
.chat-camera:not(.is-review) .chat-cam-still, .chat-camera.is-review .chat-cam-video { display: none; }
.chat-cam-msgbox { max-width: 420px; display: grid; gap: 16px; justify-items: center; text-align: center; }
.chat-cam-msg { margin: 0; line-height: 1.5; color: rgba(255, 255, 255, 0.88); }
.chat-cam-controls {
flex: none; display: flex; align-items: center; justify-content: center; gap: 24px;
padding: 18px 16px; padding-bottom: max(18px, env(safe-area-inset-bottom));
}
.chat-camera .chat-cam-review, .chat-camera.is-review .chat-cam-live { display: none; }
.chat-camera.is-review .chat-cam-review { display: flex; }
.chat-cam-side { flex: 0 1 110px; display: flex; justify-content: center; }
.chat-shutter {
flex: none; width: 74px; height: 74px; padding: 0; border-radius: 50%; border: 4px solid #fff;
background: rgba(255, 255, 255, 0.18); box-shadow: inset 0 0 0 4px rgba(0, 0, 0, 0.35); cursor: pointer;
}
.chat-shutter:hover:not(:disabled) { background: rgba(255, 255, 255, 0.35); }
.chat-shutter:active:not(:disabled) { background: #fff; }
.chat-shutter:disabled { opacity: 0.4; cursor: default; }
/* App-Ansicht (gleiche Bedingung wie in kundenbereich.css: Handy, Tablet hochkant, Handy quer):
Senden als runder Knopf mit Symbol */
@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) {
.chat-send { width: 46px; padding: 0; }
.chat-send .chat-ico { display: inline-grid; }
.chat-send .chat-send-label { display: none; }
}
@media (max-width: 640px) {
.chat-composer .chat-input-row { gap: 6px; }
.chat-composer textarea { font-size: 16px; } /* iOS zoomt sonst beim Tippen hinein */
.chat-icon-btn { width: 42px; height: 42px; }
.chat-send { width: 42px; height: 42px; }
.chat-rec { height: 42px; }
.chat-rec-label { display: none; }
.chat-tray-item.is-file { width: 190px; }
.chat-tray-item.is-voice { width: 250px; }
}
@media (prefers-reduced-motion: reduce) {
.chat-rec-dot { animation: none; }
.chat-rec-wave i, .chat-tray-progress { transition: none; }
}
+2
View File
@@ -19,6 +19,7 @@
<link rel="stylesheet" href="/neu/tokens.css">
<link rel="stylesheet" href="/neu/basis.css">
<link rel="stylesheet" href="/neu/huelle.css">
<link rel="stylesheet" href="/neu/chat-composer.css">
<link rel="stylesheet" href="/neu/ansichten.css">
<link rel="stylesheet" href="/neu/funktionen.css">
<link rel="stylesheet" href="/neu/module.css">
@@ -83,6 +84,7 @@
<div id="kn-ansage" class="kn-nur-leser" aria-live="polite"></div>
<script src="/kundenansicht.js"></script>
<script src="/chat-composer.js"></script>
<script type="module" src="/neu/huelle.js"></script>
</body>
</html>