From 8c8d29657d3b1a4027f815e59c9929bba482130f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:59:28 +0000 Subject: [PATCH] Kundenansicht neu P2: Ansicht Chat mit dem Composer und lilafreier CSS-Kopie Co-Authored-By: Claude Sonnet 5.5 --- public/neu/ansicht-chat.js | 147 ++++++++++++++++++++- public/neu/chat-composer.css | 242 +++++++++++++++++++++++++++++++++++ public/neu/dashboard.html | 2 + test/knAnsichtChat.test.js | 79 ++++++++++++ test/knHuelleSeite.test.js | 2 +- test/support/knDaten.js | 12 +- 6 files changed, 475 insertions(+), 9 deletions(-) create mode 100644 public/neu/chat-composer.css create mode 100644 test/knAnsichtChat.test.js diff --git a/public/neu/ansicht-chat.js b/public/neu/ansicht-chat.js index ed44130..1ac7bad 100644 --- a/public/neu/ansicht-chat.js +++ b/public/neu/ansicht-chat.js @@ -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 ' · …'. */ +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 } diff --git a/public/neu/chat-composer.css b/public/neu/chat-composer.css new file mode 100644 index 0000000..9eae6b1 --- /dev/null +++ b/public/neu/chat-composer.css @@ -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; } +} diff --git a/public/neu/dashboard.html b/public/neu/dashboard.html index 77f85e4..38aae71 100644 --- a/public/neu/dashboard.html +++ b/public/neu/dashboard.html @@ -19,6 +19,7 @@ + @@ -83,6 +84,7 @@
+ diff --git a/test/knAnsichtChat.test.js b/test/knAnsichtChat.test.js new file mode 100644 index 0000000..e5f525b --- /dev/null +++ b/test/knAnsichtChat.test.js @@ -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, /