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>
+79
View File
@@ -0,0 +1,79 @@
import { test, mock } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, existsSync } from 'node:fs'
import { zeigeChat, CHAT_TEXTE, nachrichtMeta } from '../public/neu/ansicht-chat.js'
import { setzeChatVorgabe } from '../public/neu/ansicht-hilfen.js'
import { mit, me, nachricht, neueWurzel } from './support/knDaten.js'
// Attrappe des klassischen Skripts chat-composer.js
function stub() {
const log = { create: null, destroyed: 0 }
globalThis.ChatComposer = {
create(opts) {
log.create = opts
opts.root.append(document.createElement('textarea'))
return { destroy() { log.destroyed += 1 }, focus() {} }
},
renderMessage(msg, { meta }) {
const bubble = document.createElement('div'); bubble.className = 'chat-bubble'; bubble.setAttribute('data-id', msg.id); bubble.append(`${meta} ${msg.text}`)
return bubble
},
trackScroll() {},
}
return log
}
const NACHRICHTEN = { messages: [nachricht({ id: 'm1', sender: 'admin', senderName: 'Kenso', text: 'Hallo', createdAt: '2026-10-14T08:00:00.000Z' }), nachricht({ id: 'm2', sender: 'customer', text: 'Danke', createdAt: '2026-10-14T08:05:00.000Z' })], unread: 1 }
test('Chat: Blasen, Composer-Optionen, genau eine Gelesen-Markierung', mit({ 'GET /api/chat/messages': NACHRICHTEN, 'POST /api/chat/read': { updated: 1 } }, async (fx) => {
const log = stub(); const events = []; fx.document.dispatchEvent = (e) => events.push(`${e.type}:${e.detail.was}`)
const w = neueWurzel(fx); await zeigeChat(w, { route: { bereich: 'chat', query: '' }, me: me(), nav: 'tab' }); await fx.ruhe()
assert.deepEqual(fx.alle(w, '.chat-bubble').map((e) => e.textContent), ['Kenso · 14.10.26, 10:00 Hallo', 'Du · 14.10.26, 10:05 Danke'])
assert.deepEqual([log.create.uploadUrl, log.create.placeholder, log.create.disabled], ['/api/chat/attachments', 'Nachricht an das WEBklar-Team…', false])
assert.equal(fx.fetchLog.filter((a) => a.method === 'POST' && a.url === '/api/chat/read').length, 1); assert.ok(events.includes('kn:zaehler-neu:chat'))
}))
test('Chat im Ansichtsmodus: Composer gesperrt, kein schreibender Aufruf', mit({ 'GET /api/chat/messages': NACHRICHTEN }, async (fx) => {
const log = stub(); await zeigeChat(neueWurzel(fx), { route: { bereich: 'chat', query: '' }, me: me({ viewAs: true }), nav: 'tab' }); await fx.ruhe()
assert.equal(log.create.disabled, true); assert.equal(log.create.disabledText, 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.')
assert.deepEqual(fx.fetchLog.filter((a) => a.method !== 'GET'), [])
}))
test('Chat: senden, Poll mit since, Dopplungen, zweiter Aufruf räumt auf', mit({ 'GET /api/chat/messages': NACHRICHTEN, 'POST /api/chat/read': {}, 'POST /api/chat/messages': { message: { id: 'm3' } } }, async (fx) => {
mock.timers.enable({ apis: ['setInterval'] })
try {
const log = stub(); const w = neueWurzel(fx); const args = { route: { bereich: 'chat', query: '' }, me: me(), nav: 'tab' }
await zeigeChat(w, args); await fx.ruhe()
assert.deepEqual(await log.create.send({ text: 'Hi', attachmentIds: ['a1'] }), { id: 'm3' })
assert.deepEqual(fx.fetchLog.at(-1).body, { text: 'Hi', attachmentIds: ['a1'] })
mock.timers.tick(10000); await fx.ruhe()
assert.equal(fx.fetchLog.at(-1).url, '/api/chat/messages?since=2026-10-14T08%3A05%3A00.000Z'); assert.equal(fx.alle(w, '.chat-bubble').length, 2)
await zeigeChat(w, args); await fx.ruhe(); assert.equal(log.destroyed, 1)
const vorher = fx.fetchLog.length; mock.timers.tick(10000); await fx.ruhe(); assert.equal(fx.fetchLog.filter((a, i) => i >= vorher && a.url.includes('since=')).length, 1)
} finally { mock.timers.reset() }
}))
test('Chat: Vorgabe aus „Postfach anfragen“ landet im Eingabefeld, leerer Verlauf sagt es', mit({ 'GET /api/chat/messages': { messages: [], unread: 0 }, 'POST /api/chat/read': {} }, async (fx) => {
stub(); setzeChatVorgabe('Bitte lege ein neues Postfach an: '); const w = neueWurzel(fx); await zeigeChat(w, { route: { bereich: 'chat', query: '' }, me: me(), nav: 'tab' }); await fx.ruhe()
assert.equal(fx.finde(w, 'textarea').value, 'Bitte lege ein neues Postfach an: '); assert.match(fx.text(w), /Noch keine Nachrichten\./)
}))
test('dashboard.html lädt den Composer klassisch und die bereinigte CSS-Kopie zwischen huelle.css und ansichten.css, nie das alte /chat-composer.css', () => {
const html = readFileSync(new URL('../public/neu/dashboard.html', import.meta.url), 'utf8')
assert.match(html, /<script src="\/kundenansicht\.js"><\/script>\s*<script src="\/chat-composer\.js"><\/script>[\s\S]*<script type="module" src="\/neu\/huelle\.js">/)
const i = (s) => html.indexOf(s); assert.ok(i('href="/neu/huelle.css"') < i('href="/neu/chat-composer.css"') && i('href="/neu/chat-composer.css"') < i('href="/neu/ansichten.css"'))
assert.doesNotMatch(html, /href="\/chat-composer\.css"/)
})
const lesePublic = (p) => readFileSync(new URL(`../public/${p}`, import.meta.url), 'utf8')
const geruest = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\{[^{}]*\}/g, '{}').replace(/\s+/g, ' ').trim() // Selektoren und Verschachtelung ohne Deklarationen
test('public/neu/chat-composer.css: kein Lila, kein Gewicht 800, kein Glas, helle Variablen', () => {
const kopie = lesePublic('neu/chat-composer.css')
assert.doesNotMatch(kopie, /#9d3dfe|rgba?\(\s*157\s*,\s*61\s*,\s*254|font-weight:\s*800|backdrop-filter/i)
const wurzel = kopie.match(/:root\s*\{([^}]*)\}/)[1]
for (const v of ['accent', 'accent-hover', 'accent-soft', 'accent-line', 'accent-text', 'on-accent', 'line', 'muted', 'surface']) assert.match(wurzel, new RegExp(`--chat-${v}:`), v)
assert.match(wurzel, /--chat-accent:\s*#111111/); assert.match(wurzel, /--chat-surface:\s*#ffffff/)
})
test('public/neu/chat-composer.css: gleiche Selektoren in gleicher Reihenfolge wie das Original (Kopie driftet nicht)', { skip: !existsSync(new URL('../public/chat-composer.css', import.meta.url)) }, () => {
assert.equal(geruest(lesePublic('neu/chat-composer.css')), geruest(lesePublic('chat-composer.css')))
})
test('Chat: Texte und Meta der Nachricht', () => {
assert.deepEqual(CHAT_TEXTE, { platzhalter: 'Nachricht an das WEBklar-Team…', kurz: 'Nachricht…', gesperrt: 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.', leer: 'Noch keine Nachrichten.' })
assert.equal(nachrichtMeta(nachricht({ sender: 'customer', createdAt: '2026-10-14T08:05:00.000Z' })), 'Du · 14.10.26, 10:05')
assert.equal(nachrichtMeta(nachricht({ sender: 'admin', senderName: '', createdAt: '2026-10-14T08:05:00.000Z' })), 'Webklar-Team · 14.10.26, 10:05')
})
+1 -1
View File
@@ -10,7 +10,7 @@ test('dashboard.html: weiße PWA-Metas, Stile in fester Reihenfolge, klassisches
assert.match(html, /apple-mobile-web-app-status-bar-style" content="default"/); assert.match(html, /<link rel="manifest" href="\/neu\/manifest\.webmanifest">/)
assert.match(html, /viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"/)
const css = [...html.matchAll(/<link rel="stylesheet" href="([^"]+)"/g)].map((m) => m[1]); const ORDNUNG = ['/neu/tokens.css', '/neu/basis.css', '/neu/huelle.css', '/neu/ansichten.css', '/neu/funktionen.css', '/neu/module.css']
assert.deepEqual(css.filter((f) => f !== '/chat-composer.css'), ORDNUNG); assert.ok(css.indexOf('/chat-composer.css') === -1 || css.indexOf('/chat-composer.css') > css.indexOf('/neu/huelle.css'))
assert.deepEqual(css.filter((f) => f !== '/neu/chat-composer.css'), ORDNUNG); assert.ok(css.indexOf('/neu/chat-composer.css') === -1 || css.indexOf('/neu/chat-composer.css') > css.indexOf('/neu/huelle.css'))
assert.match(html, /<body class="kn">/)
const skripte = [...html.matchAll(/<script([^>]*)>/g)].map((m) => m[1].trim())
assert.deepEqual([skripte[0], skripte.at(-1)], ['src="/kundenansicht.js"', 'type="module" src="/neu/huelle.js"']); assert.ok(skripte.slice(1, -1).every((s) => s === 'src="/chat-composer.js"'))
+8 -4
View File
@@ -37,12 +37,16 @@ function ereignisseFuerDokument(document) {
}
function mitAttrappen(fx) {
const vorher = Object.getOwnPropertyDescriptor(globalThis, 'CustomEvent')
Object.defineProperty(globalThis, 'CustomEvent', { value: EreignisAttrappe, writable: true, configurable: true, enumerable: true })
// Event und CustomEvent: die echten Klassen haben nur lesbare Felder (target), fakeDom schreibt sie
const namen = ['CustomEvent', 'Event']
const vorher = namen.map((n) => [n, Object.getOwnPropertyDescriptor(globalThis, n)])
for (const n of namen) Object.defineProperty(globalThis, n, { value: EreignisAttrappe, writable: true, configurable: true, enumerable: true })
ereignisseFuerDokument(fx.document)
return () => {
if (vorher) Object.defineProperty(globalThis, 'CustomEvent', vorher)
else delete globalThis.CustomEvent
for (const [n, d] of vorher) {
if (d) Object.defineProperty(globalThis, n, d)
else delete globalThis[n]
}
}
}