Chat im Ticketsystem: Messenger unter Kunden, Reiter Chat auf der Kundenseite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
005875d970
commit
eb0b889203
7 files changed
+299
-3
No files matched your search
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { FaRotateRight } from 'react-icons/fa6'
|
||||
import { Button } from '../ui'
|
||||
import { PORTAL_URL, chatUploadUrl, getChatMessages, markChatRead, sendChatMessage, teamAuthHeaders } from '../../lib/teamApi'
|
||||
import { loadChatComposer } from '../../lib/loadChatComposer'
|
||||
import { messageMeta, needsFullRefresh, noticeText } from '../../lib/chatFormat'
|
||||
import '../../styles/chat.css'
|
||||
|
||||
const POLL_MS = 15 * 1000
|
||||
|
||||
/**
|
||||
* Gespraech mit einem Kunden (Messenger und Reiter "Chat"). Sprechblasen und Eingabezeile kommen aus dem
|
||||
* Chat-Baustein des Portals (window.ChatComposer); React verwaltet nur die leeren Container.
|
||||
* Antworten fuer einen inzwischen gewechselten Kunden werden verworfen (alive je Effekt).
|
||||
*/
|
||||
export default function ChatThread({ customerId, customerName }) {
|
||||
const zoneRef = useRef(null)
|
||||
const boxRef = useRef(null)
|
||||
const composerRef = useRef(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState('')
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
let composer = null
|
||||
let timer = null
|
||||
let chat = null
|
||||
const state = { shown: new Set(), last: '', fullAt: 0 }
|
||||
setError(null)
|
||||
setNotice('')
|
||||
|
||||
const append = (messages) => {
|
||||
const box = boxRef.current
|
||||
if (!alive || !box || !chat) return
|
||||
let added = false
|
||||
for (const msg of messages) {
|
||||
if (state.shown.has(msg.id)) continue
|
||||
state.shown.add(msg.id)
|
||||
box.appendChild(chat.renderMessage(msg, {
|
||||
mine: msg.sender === 'admin',
|
||||
meta: messageMeta(msg, customerName),
|
||||
fileUrl: (att) => att.url,
|
||||
}))
|
||||
if (msg.createdAt > state.last) state.last = msg.createdAt
|
||||
added = true
|
||||
}
|
||||
if (added) box.scrollTop = box.scrollHeight
|
||||
}
|
||||
|
||||
// Ganzer Verlauf mit frischen signierten Links (beim Oeffnen und nach 10 Minuten)
|
||||
const loadAll = async () => {
|
||||
const { messages } = await getChatMessages(customerId)
|
||||
if (!alive) return
|
||||
boxRef.current?.replaceChildren()
|
||||
state.shown = new Set()
|
||||
state.last = ''
|
||||
state.fullAt = Date.now()
|
||||
append(messages || [])
|
||||
markChatRead(customerId).catch(() => {})
|
||||
}
|
||||
|
||||
const poll = async () => {
|
||||
if (!alive || document.visibilityState === 'hidden' || !chat) return
|
||||
try {
|
||||
if (needsFullRefresh(state.fullAt, Date.now())) return await loadAll()
|
||||
const { messages } = await getChatMessages(customerId, state.last)
|
||||
if (!alive) return
|
||||
append(messages || [])
|
||||
if ((messages || []).some((m) => m.sender === 'customer')) markChatRead(customerId).catch(() => {})
|
||||
} catch {
|
||||
/* naechste Abfrage versucht es erneut */
|
||||
}
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
chat = await loadChatComposer({ baseUrl: PORTAL_URL })
|
||||
if (!alive) return
|
||||
chat.trackScroll(boxRef.current)
|
||||
composer = chat.create({
|
||||
root: composerRef.current,
|
||||
dropZone: zoneRef.current,
|
||||
uploadUrl: chatUploadUrl(customerId),
|
||||
requestHeaders: teamAuthHeaders,
|
||||
placeholder: `Antwort an ${customerName || 'Kunde'}…`,
|
||||
placeholderShort: 'Antwort…',
|
||||
send: async ({ text, attachmentIds }) => {
|
||||
const { message, notice: result } = await sendChatMessage(customerId, { text, attachmentIds })
|
||||
const time = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
if (alive) setNotice(noticeText(result, time))
|
||||
return message
|
||||
},
|
||||
onSent: (message) => append([message]),
|
||||
})
|
||||
await loadAll()
|
||||
timer = setInterval(poll, POLL_MS)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message || 'Die Unterhaltung konnte nicht geladen werden.')
|
||||
}
|
||||
})()
|
||||
|
||||
const onVisible = () => { if (document.visibilityState === 'visible') poll() }
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => {
|
||||
alive = false
|
||||
clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
composer?.destroy()
|
||||
}
|
||||
}, [customerId, customerName, reloadKey])
|
||||
|
||||
return (
|
||||
<div className="woms-chat" ref={zoneRef}>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
<span className="muted">{error}</span>
|
||||
<Button variant="ghost" onClick={() => setReloadKey((k) => k + 1)}><FaRotateRight /> Erneut versuchen</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-messages" ref={boxRef} aria-live="polite" />
|
||||
{notice && <div className="chat-notice muted">{notice}</div>}
|
||||
<div ref={composerRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user