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:
1 parent
a3831040ba
commit
8c8d29657d
6 files changed
+475
-9
No files matched your search
+143
-4
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user