From 7b6d855fec6d58bd7f5fdb3697790a002700057d Mon Sep 17 00:00:00 2001 From: rajchales-monito Date: Thu, 23 Jul 2026 22:53:07 +0200 Subject: [PATCH] Fix widget message persistence --- public/widget.js | 93 ++++++++++++++++++++++++++++++++++-------------- server.js | 33 ++++++++++++++++- 2 files changed, 99 insertions(+), 27 deletions(-) diff --git a/public/widget.js b/public/widget.js index 07e2d75..ddff0ad 100644 --- a/public/widget.js +++ b/public/widget.js @@ -119,39 +119,32 @@ event.preventDefault(); const body = await payloadFromForm(form, selectedFiles); if (!body.message.trim()) return; - addMessage(messages, "visitor", body.message, []); - form.message.value = ""; - selectedFiles = []; - renderSelectedFiles(attachmentList, selectedFiles); + const submitButton = root.querySelector(".mf-send"); + submitButton.disabled = true; const url = session.conversationId ? `${apiBase}/api/widget/conversations/${session.conversationId}/messages` : `${apiBase}/api/widget/conversations`; - const data = await postJson(url, { - ...body, - siteKey, - visitorToken: session.visitorToken, - currentUrl: location.href, - referrer: document.referrer, - browsingHistory: rememberPage(), - language: navigator.language, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - device: { - userAgent: navigator.userAgent, - platform: navigator.platform, - viewport: `${innerWidth}x${innerHeight}` - } - }); - - session.conversationId = data.conversation.conversation.id; - session.visitorToken = data.visitorToken || session.visitorToken; - saveSession(); - renderConversation(messages, data.conversation); - connectEvents(messages, typing); + try { + const data = await postJson(url, withVisitorContext(body)); + session.conversationId = data.conversation.id; + saveSession(); + form.message.value = ""; + selectedFiles = []; + renderSelectedFiles(attachmentList, selectedFiles); + renderConversation(messages, data); + connectEvents(messages, typing); + } catch { + showFormError(root, "Zpravu se nepodarilo odeslat. Zkuste to prosim znovu."); + } finally { + submitButton.disabled = false; + } }); rememberPage(); - if (session.conversationId) connectEvents(messages, typing); + if (session.conversationId) { + loadExistingConversation(messages).finally(() => connectEvents(messages, typing)); + } } async function payloadFromForm(form, selectedFiles) { @@ -176,6 +169,16 @@ }); } + async function loadExistingConversation(messages) { + try { + const data = await fetchJson(`${apiBase}/api/widget/conversations/${session.conversationId}?visitorToken=${encodeURIComponent(session.visitorToken)}`); + renderConversation(messages, data); + } catch { + session.conversationId = null; + saveSession(); + } + } + function renderConversation(messages, data) { messages.innerHTML = ""; for (const message of data.messages) { @@ -212,6 +215,35 @@ `).join(""); } + function withVisitorContext(body) { + return { + ...body, + siteKey, + visitorToken: session.visitorToken, + currentUrl: location.href, + referrer: document.referrer, + browsingHistory: rememberPage(), + language: navigator.language, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + device: { + userAgent: navigator.userAgent, + platform: navigator.platform, + viewport: `${innerWidth}x${innerHeight}` + } + }; + } + + function showFormError(root, message) { + const note = root.querySelector(".mf-note"); + note.textContent = message; + note.classList.add("error"); + clearTimeout(showFormError.timer); + showFormError.timer = setTimeout(() => { + note.textContent = "Odeslanim zpravy nam predavate udaje potrebne pro odpoved."; + note.classList.remove("error"); + }, 3500); + } + function isAllowedImageFile(file) { return allowedImageTypes.has(file.type) && file.size > 0 && file.size <= maxAttachmentBytes; } @@ -267,6 +299,7 @@ input, textarea { width: 100%; border: 1px solid #dfe6e2; border-radius: 6px; padding: 9px 10px; color: ${c.text}; background: white; } textarea { resize: vertical; min-height: 72px; } .mf-send { width: 42px; height: 42px; display: grid; place-items: center; border: 0; border-radius: 50%; background: ${c.brand}; color: ${c.brandText}; cursor: pointer; } + .mf-send:disabled { opacity: .55; cursor: wait; } .mf-send svg { width: 22px; height: 22px; fill: currentColor; transform: translateX(1px); } .mf-dropzone { min-height: 42px; border: 1px dashed #b9c9c1; border-radius: 8px; display: flex; align-items: center; gap: 8px; padding: 9px 10px; color: ${c.muted}; background: #f8fbf9; font-size: 12px; cursor: pointer; } .mf-dropzone.dragging { border-color: ${c.brand}; background: #eef8f4; color: ${c.text}; } @@ -277,6 +310,7 @@ .mf-attachment-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 210px; } .mf-attachment-chip button { width: 22px; height: 22px; border: 0; border-radius: 50%; background: #e8eee9; color: ${c.text}; cursor: pointer; padding: 0; } .mf-note { margin: 0; color: ${c.muted}; font-size: 11px; line-height: 1.3; } + .mf-note.error { color: #b42318; } .mf-powered { justify-self: center; color: ${c.muted}; font-size: 11px; text-decoration: none; } .mf-powered:hover { color: ${c.brand}; text-decoration: underline; } @media (max-width: 640px) { @@ -321,6 +355,13 @@ }); } + function fetchJson(url) { + return fetch(url).then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }); + } + function fileToPayload(file) { return new Promise((resolve) => { const reader = new FileReader(); diff --git a/server.js b/server.js index 0ca616f..3948e5a 100644 --- a/server.js +++ b/server.js @@ -63,6 +63,7 @@ async function route(req, res) { if (url.pathname === "/api/widget/config" && req.method === "GET") return widgetConfig(res, url); if (url.pathname === "/api/widget/conversations" && req.method === "POST") return createConversation(req, res); + if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+$/) && req.method === "GET") return getVisitorConversation(res, url); if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createVisitorMessage(req, res, url); if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/events$/) && req.method === "GET") return eventStream(req, res, url, "visitor"); if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/typing$/) && req.method === "POST") return visitorTyping(req, res, url); @@ -325,6 +326,13 @@ function widgetConfig(res, url) { return json(res, 200, { site: formatSite(site), publicBaseUrl: PUBLIC_BASE_URL }); } +function getVisitorConversation(res, url) { + const conversation = findConversation(publicIdFrom(url)); + if (!conversation) return json(res, 404, { error: "conversation_not_found" }); + if (conversation.visitor_token !== url.searchParams.get("visitorToken")) return json(res, 403, { error: "forbidden" }); + return json(res, 200, conversationDetails(conversation)); +} + async function createConversation(req, res) { const body = await readJson(req, 8_000_000); if (!body.message || !String(body.message).trim()) return json(res, 400, { error: "message_required" }); @@ -371,7 +379,7 @@ async function createConversation(req, res) { const conversation = db.prepare("SELECT * FROM conversations WHERE id = ?").get(insert); publish("admin", { type: "conversation:new", conversation: formatConversationSummary(conversation) }); publishConversation(conversation, { type: "message:new", payload: conversationDetails(conversation) }); - return json(res, 201, { conversation: conversationDetails(conversation), visitorToken }); + return json(res, 201, conversationDetails(conversation)); } async function createVisitorMessage(req, res, url) { @@ -382,6 +390,7 @@ async function createVisitorMessage(req, res, url) { if (!conversation) return json(res, 404, { error: "conversation_not_found" }); const messageId = insertMessage(conversation.id, "visitor", conversation.visitor_name || "Visitor", String(body.message).trim()); saveAttachments(conversation.id, messageId, body.attachments || []); + updateVisitorContext(conversation.id, body); bumpConversation(conversation.id, conversation.status === "resolved" ? "open" : conversation.status); const updated = findConversation(conversation.public_id); publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) }); @@ -482,6 +491,28 @@ function bumpConversation(conversationId, status) { `).run(status, conversationId); } +function updateVisitorContext(conversationId, body) { + db.prepare(` + UPDATE conversations + SET current_url = COALESCE(?, current_url), + referrer = COALESCE(?, referrer), + browsing_history_json = COALESCE(?, browsing_history_json), + device_json = COALESCE(?, device_json), + language = COALESCE(?, language), + timezone = COALESCE(?, timezone), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run( + nullish(body.currentUrl), + nullish(body.referrer), + body.browsingHistory ? JSON.stringify(body.browsingHistory) : null, + body.device ? JSON.stringify(body.device) : null, + nullish(body.language), + nullish(body.timezone), + conversationId + ); +} + function conversationDetails(conversation) { const messages = db.prepare("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id); const attachments = db.prepare("SELECT * FROM attachments WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);