Fix widget message persistence
This commit is contained in:
+67
-26
@@ -119,39 +119,32 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const body = await payloadFromForm(form, selectedFiles);
|
const body = await payloadFromForm(form, selectedFiles);
|
||||||
if (!body.message.trim()) return;
|
if (!body.message.trim()) return;
|
||||||
addMessage(messages, "visitor", body.message, []);
|
const submitButton = root.querySelector(".mf-send");
|
||||||
form.message.value = "";
|
submitButton.disabled = true;
|
||||||
selectedFiles = [];
|
|
||||||
renderSelectedFiles(attachmentList, selectedFiles);
|
|
||||||
|
|
||||||
const url = session.conversationId
|
const url = session.conversationId
|
||||||
? `${apiBase}/api/widget/conversations/${session.conversationId}/messages`
|
? `${apiBase}/api/widget/conversations/${session.conversationId}/messages`
|
||||||
: `${apiBase}/api/widget/conversations`;
|
: `${apiBase}/api/widget/conversations`;
|
||||||
const data = await postJson(url, {
|
try {
|
||||||
...body,
|
const data = await postJson(url, withVisitorContext(body));
|
||||||
siteKey,
|
session.conversationId = data.conversation.id;
|
||||||
visitorToken: session.visitorToken,
|
saveSession();
|
||||||
currentUrl: location.href,
|
form.message.value = "";
|
||||||
referrer: document.referrer,
|
selectedFiles = [];
|
||||||
browsingHistory: rememberPage(),
|
renderSelectedFiles(attachmentList, selectedFiles);
|
||||||
language: navigator.language,
|
renderConversation(messages, data);
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
connectEvents(messages, typing);
|
||||||
device: {
|
} catch {
|
||||||
userAgent: navigator.userAgent,
|
showFormError(root, "Zpravu se nepodarilo odeslat. Zkuste to prosim znovu.");
|
||||||
platform: navigator.platform,
|
} finally {
|
||||||
viewport: `${innerWidth}x${innerHeight}`
|
submitButton.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
session.conversationId = data.conversation.conversation.id;
|
|
||||||
session.visitorToken = data.visitorToken || session.visitorToken;
|
|
||||||
saveSession();
|
|
||||||
renderConversation(messages, data.conversation);
|
|
||||||
connectEvents(messages, typing);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
rememberPage();
|
rememberPage();
|
||||||
if (session.conversationId) connectEvents(messages, typing);
|
if (session.conversationId) {
|
||||||
|
loadExistingConversation(messages).finally(() => connectEvents(messages, typing));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function payloadFromForm(form, selectedFiles) {
|
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) {
|
function renderConversation(messages, data) {
|
||||||
messages.innerHTML = "";
|
messages.innerHTML = "";
|
||||||
for (const message of data.messages) {
|
for (const message of data.messages) {
|
||||||
@@ -212,6 +215,35 @@
|
|||||||
`).join("");
|
`).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) {
|
function isAllowedImageFile(file) {
|
||||||
return allowedImageTypes.has(file.type) && file.size > 0 && file.size <= maxAttachmentBytes;
|
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; }
|
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; }
|
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 { 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-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 { 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}; }
|
.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 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-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 { 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 { justify-self: center; color: ${c.muted}; font-size: 11px; text-decoration: none; }
|
||||||
.mf-powered:hover { color: ${c.brand}; text-decoration: underline; }
|
.mf-powered:hover { color: ${c.brand}; text-decoration: underline; }
|
||||||
@media (max-width: 640px) {
|
@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) {
|
function fileToPayload(file) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
|||||||
@@ -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/config" && req.method === "GET") return widgetConfig(res, url);
|
||||||
if (url.pathname === "/api/widget/conversations" && req.method === "POST") return createConversation(req, res);
|
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\/[^/]+\/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\/[^/]+\/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);
|
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 });
|
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) {
|
async function createConversation(req, res) {
|
||||||
const body = await readJson(req, 8_000_000);
|
const body = await readJson(req, 8_000_000);
|
||||||
if (!body.message || !String(body.message).trim()) return json(res, 400, { error: "message_required" });
|
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);
|
const conversation = db.prepare("SELECT * FROM conversations WHERE id = ?").get(insert);
|
||||||
publish("admin", { type: "conversation:new", conversation: formatConversationSummary(conversation) });
|
publish("admin", { type: "conversation:new", conversation: formatConversationSummary(conversation) });
|
||||||
publishConversation(conversation, { type: "message:new", payload: conversationDetails(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) {
|
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" });
|
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||||
const messageId = insertMessage(conversation.id, "visitor", conversation.visitor_name || "Visitor", String(body.message).trim());
|
const messageId = insertMessage(conversation.id, "visitor", conversation.visitor_name || "Visitor", String(body.message).trim());
|
||||||
saveAttachments(conversation.id, messageId, body.attachments || []);
|
saveAttachments(conversation.id, messageId, body.attachments || []);
|
||||||
|
updateVisitorContext(conversation.id, body);
|
||||||
bumpConversation(conversation.id, conversation.status === "resolved" ? "open" : conversation.status);
|
bumpConversation(conversation.id, conversation.status === "resolved" ? "open" : conversation.status);
|
||||||
const updated = findConversation(conversation.public_id);
|
const updated = findConversation(conversation.public_id);
|
||||||
publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) });
|
publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) });
|
||||||
@@ -482,6 +491,28 @@ function bumpConversation(conversationId, status) {
|
|||||||
`).run(status, conversationId);
|
`).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) {
|
function conversationDetails(conversation) {
|
||||||
const messages = db.prepare("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
|
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);
|
const attachments = db.prepare("SELECT * FROM attachments WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user