Add lazy widget language localization

This commit is contained in:
rajchales-monito
2026-07-30 08:13:57 +02:00
parent 05636ab77e
commit 80540cf188
5 changed files with 439 additions and 38 deletions
+12 -1
View File
@@ -226,9 +226,20 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
margin: 0; margin: 0;
} }
.effect-settings .full-row, .effect-settings .full-row,
.effect-settings .ai-help { .effect-settings .ai-help,
.widget-language-settings small {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.widget-copy-settings label {
grid-column: 1 / -1;
display: grid;
align-items: stretch;
}
.widget-copy-settings input,
.widget-copy-settings textarea,
.widget-language-settings select {
width: 100%;
}
.effect-settings .full-row { .effect-settings .full-row {
display: grid; display: grid;
align-items: stretch; align-items: stretch;
+26 -4
View File
@@ -114,10 +114,32 @@
</div> </div>
<section class="settings-tab-panel active" data-settings-panel="widget"> <section class="settings-tab-panel active" data-settings-panel="widget">
<form id="settingsForm"> <form id="settingsForm">
<label>Titulek CS <input name="titleCs"></label> <fieldset class="effect-settings widget-language-settings">
<label>Intro CS <textarea name="introCs" rows="2"></textarea></label> <legend>Jazyk widgetu</legend>
<label>Titulek EN <input name="titleEn"></label> <label>Rezim
<label>Intro EN <textarea name="introEn" rows="2"></textarea></label> <select name="widgetLanguageMode">
<option value="auto">auto podle stranky</option>
<option value="fixed">natvrdo vybrany jazyk</option>
</select>
</label>
<label>Vychozi jazyk textu <select name="defaultLanguage"></select></label>
<label>Natvrdo jazyk <select name="fixedLanguage"></select></label>
<label>Fallback jazyk <select name="fallbackLanguage"></select></label>
<small>Chybejici jazyk widgetu se pri prvnim pouziti prelozi AI a ulozi.</small>
</fieldset>
<fieldset class="effect-settings widget-copy-settings">
<legend>Texty vychoziho jazyka</legend>
<label>Titulek <input name="copyTitle"></label>
<label>Intro <textarea name="copyIntro" rows="2"></textarea></label>
<label>Offline intro <textarea name="copyOfflineIntro" rows="2"></textarea></label>
<label>Placeholder <input name="copyPlaceholder"></label>
<label>Odeslat <input name="copySendLabel"></label>
<label>Pridani fotky <input name="copyDropzoneLabel"></label>
<label>Online stitek <input name="copyOnlineLabel"></label>
<label>Nova odpoved <input name="copyNewReplyLabel"></label>
<label>Operator odpovida <input name="copyOperatorReplyLabel"></label>
<label>Info/GDPR <textarea name="copyInfoText" rows="2"></textarea></label>
</fieldset>
<label class="color-field">Barva <label class="color-field">Barva
<span class="color-row"> <span class="color-row">
<input name="brand" type="color"> <input name="brand" type="color">
+128 -8
View File
@@ -24,6 +24,15 @@ const state = {
}; };
const $ = (selector) => document.querySelector(selector); const $ = (selector) => document.querySelector(selector);
const WIDGET_LANGUAGES = [
["cs", "Cestina"], ["sk", "Slovenstina"], ["en", "Anglictina"], ["de", "Nemcina"], ["pl", "Polstina"],
["hu", "Madarstina"], ["ro", "Rumunstina"], ["bg", "Bulharstina"], ["hr", "Chorvatstina"], ["sl", "Slovinstina"],
["it", "Italstina"], ["fr", "Francouzstina"], ["es", "Spanelstina"], ["pt", "Portugalstina"], ["nl", "Nizozemstina"],
["da", "Danstina"], ["sv", "Svedstina"], ["fi", "Finstina"], ["no", "Norstina"], ["et", "Estonstina"],
["lv", "Lotystina"], ["lt", "Litevstina"], ["el", "Rectina"], ["uk", "Ukrajinstina"], ["ru", "Rustina"],
["tr", "Turectina"], ["sr", "Srb-stina"], ["bs", "Bosenstina"], ["sq", "Albanstina"], ["mk", "Makedonstina"],
["mt", "Maltstina"], ["ga", "Irstina"], ["is", "Islandstina"], ["be", "Belorustina"], ["ca", "Katalanstina"]
];
boot(); boot();
renderAdminSoundControls(); renderAdminSoundControls();
@@ -104,6 +113,9 @@ $("#settingsForm").brandHex.addEventListener("input", (event) => {
const color = normalizeHexColor(event.target.value); const color = normalizeHexColor(event.target.value);
if (color) $("#settingsForm").brand.value = color; if (color) $("#settingsForm").brand.value = color;
}); });
$("#settingsForm").defaultLanguage.addEventListener("change", () => {
renderWidgetCopyFields($("#settingsForm").defaultLanguage.value);
});
window.addEventListener("focus", syncAttentionWithUnread); window.addEventListener("focus", syncAttentionWithUnread);
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
@@ -118,10 +130,33 @@ $("#settingsForm").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const form = new FormData(event.currentTarget); const form = new FormData(event.currentTarget);
const settings = structuredClone(state.site.settings); const settings = structuredClone(state.site.settings);
settings.title.cs = form.get("titleCs"); const defaultLanguage = form.get("defaultLanguage") || "cs";
settings.intro.cs = form.get("introCs"); settings.widgetLanguage = {
settings.title.en = form.get("titleEn"); ...(settings.widgetLanguage || {}),
settings.intro.en = form.get("introEn"); mode: form.get("widgetLanguageMode") || "auto",
defaultLanguage,
fixedLanguage: form.get("fixedLanguage") || defaultLanguage,
fallbackLanguage: form.get("fallbackLanguage") || defaultLanguage
};
settings.widgetCopy = settings.widgetCopy || {};
settings.widgetCopy[defaultLanguage] = {
...(settings.widgetCopy[defaultLanguage] || {}),
title: form.get("copyTitle") || "",
intro: form.get("copyIntro") || "",
offlineIntro: form.get("copyOfflineIntro") || "",
placeholder: form.get("copyPlaceholder") || "",
sendLabel: form.get("copySendLabel") || "",
dropzoneLabel: form.get("copyDropzoneLabel") || "",
onlineLabel: form.get("copyOnlineLabel") || "",
offlineLabel: settings.widgetCopy[defaultLanguage]?.offlineLabel || "Offline",
newReplyLabel: form.get("copyNewReplyLabel") || "",
typingLabel: settings.widgetCopy[defaultLanguage]?.typingLabel || "Operator pise...",
infoTitle: settings.widgetCopy[defaultLanguage]?.infoTitle || "GDPR informace",
infoText: form.get("copyInfoText") || "",
closeLabel: settings.widgetCopy[defaultLanguage]?.closeLabel || "Zmensit chat",
operatorReplyLabel: form.get("copyOperatorReplyLabel") || ""
};
syncLegacyWidgetCopy(settings);
settings.colors.brand = normalizeHexColor(form.get("brandHex")) || form.get("brand"); settings.colors.brand = normalizeHexColor(form.get("brandHex")) || form.get("brand");
settings.desktop.side = form.get("desktopSide"); settings.desktop.side = form.get("desktopSide");
settings.desktop.bottomPx = Number(form.get("desktopBottom")); settings.desktop.bottomPx = Number(form.get("desktopBottom"));
@@ -378,10 +413,8 @@ function renderSite() {
$("#snippet").textContent = state.snippet; $("#snippet").textContent = state.snippet;
const s = state.site.settings; const s = state.site.settings;
const form = $("#settingsForm"); const form = $("#settingsForm");
form.titleCs.value = s.title.cs || ""; renderWidgetLanguageOptions(form, s);
form.introCs.value = s.intro.cs || ""; renderWidgetCopyFields(s.widgetLanguage?.defaultLanguage || "cs");
form.titleEn.value = s.title.en || "";
form.introEn.value = s.intro.en || "";
form.brand.value = normalizeHexColor(s.colors.brand) || "#0f8f6f"; form.brand.value = normalizeHexColor(s.colors.brand) || "#0f8f6f";
form.brandHex.value = form.brand.value; form.brandHex.value = form.brand.value;
form.desktopSide.value = s.desktop.side || "right"; form.desktopSide.value = s.desktop.side || "right";
@@ -407,6 +440,93 @@ function renderSite() {
renderModelOptions(s.translations?.model || "gpt-5-mini"); renderModelOptions(s.translations?.model || "gpt-5-mini");
} }
function renderWidgetLanguageOptions(form, settings) {
const language = settings.widgetLanguage || {};
const options = WIDGET_LANGUAGES
.map(([code, label]) => `<option value="${code}">${code.toUpperCase()} - ${escapeHtml(label)}</option>`)
.join("");
for (const name of ["defaultLanguage", "fixedLanguage", "fallbackLanguage"]) {
form[name].innerHTML = options;
}
form.widgetLanguageMode.value = language.mode || "auto";
form.defaultLanguage.value = language.defaultLanguage || "cs";
form.fixedLanguage.value = language.fixedLanguage || "cs";
form.fallbackLanguage.value = language.fallbackLanguage || language.defaultLanguage || "cs";
}
function renderWidgetCopyFields(language) {
const form = $("#settingsForm");
const settings = state.site?.settings || {};
const copy = widgetCopyForAdmin(settings, language);
form.copyTitle.value = copy.title || "";
form.copyIntro.value = copy.intro || "";
form.copyOfflineIntro.value = copy.offlineIntro || "";
form.copyPlaceholder.value = copy.placeholder || "";
form.copySendLabel.value = copy.sendLabel || "";
form.copyDropzoneLabel.value = copy.dropzoneLabel || "";
form.copyOnlineLabel.value = copy.onlineLabel || "";
form.copyNewReplyLabel.value = copy.newReplyLabel || "";
form.copyOperatorReplyLabel.value = copy.operatorReplyLabel || "";
form.copyInfoText.value = copy.infoText || "";
}
function widgetCopyForAdmin(settings, language) {
const lang = language || "cs";
const copy = settings.widgetCopy?.[lang] || {};
const legacy = {
title: settings.title?.[lang],
intro: settings.intro?.[lang],
offlineIntro: settings.offlineIntro?.[lang],
placeholder: settings.placeholder?.[lang],
sendLabel: settings.sendLabel?.[lang]
};
return { ...defaultWidgetCopy(lang), ...legacy, ...copy };
}
function defaultWidgetCopy(language) {
if (language === "en") {
return {
title: "Need help?",
intro: "Message us and we will reply as soon as possible.",
offlineIntro: "We are offline, but you can leave us a message.",
placeholder: "Write a message...",
sendLabel: "Send",
dropzoneLabel: "Add a photo or drag it here",
onlineLabel: "We are online",
newReplyLabel: "New reply",
infoText: "We process your message and technical session data only to answer your request.",
operatorReplyLabel: "is replying"
};
}
return {
title: "Potrebujete poradit?",
intro: "Napiste nam. Odpovime co nejdrive.",
offlineIntro: "Ted nejsme online, ale zpravu nam muzete nechat.",
placeholder: "Napiste zpravu...",
sendLabel: "Odeslat",
dropzoneLabel: "Pridat fotku nebo pretahnout",
onlineLabel: "Jsme online",
newReplyLabel: "Nova odpoved",
infoText: "Zpravu a technicke udaje relace zpracujeme jen pro odpoved na vas dotaz.",
operatorReplyLabel: "odpovida"
};
}
function syncLegacyWidgetCopy(settings) {
settings.title = settings.title || {};
settings.intro = settings.intro || {};
settings.offlineIntro = settings.offlineIntro || {};
settings.placeholder = settings.placeholder || {};
settings.sendLabel = settings.sendLabel || {};
for (const [language, copy] of Object.entries(settings.widgetCopy || {})) {
settings.title[language] = copy.title;
settings.intro[language] = copy.intro;
settings.offlineIntro[language] = copy.offlineIntro;
settings.placeholder[language] = copy.placeholder;
settings.sendLabel[language] = copy.sendLabel;
}
}
async function saveTranslationSettings() { async function saveTranslationSettings() {
if (!state.site) return; if (!state.site) return;
setOpenAiStatus("Ukladam AI nastaveni..."); setOpenAiStatus("Ukladam AI nastaveni...");
+53 -17
View File
@@ -2,15 +2,22 @@
const boot = window.MaalFlows || {}; const boot = window.MaalFlows || {};
const apiBase = (boot.apiBase || "").replace(/\/$/, "") || new URL(document.currentScript.src).origin; const apiBase = (boot.apiBase || "").replace(/\/$/, "") || new URL(document.currentScript.src).origin;
const siteKey = boot.siteKey || "9b-plus"; const siteKey = boot.siteKey || "9b-plus";
const lang = (boot.lang || document.documentElement.lang || navigator.language || "cs").slice(0, 2).toLowerCase(); const lang = detectPageLanguage(boot);
const storageKey = `maalflows:${siteKey}`; const storageKey = `maalflows:${siteKey}`;
const pendingStorageKey = `${storageKey}:pending`; const pendingStorageKey = `${storageKey}:pending`;
const session = loadSession(); const session = loadSession();
const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]); const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
const maxAttachmentBytes = 3_000_000; const maxAttachmentBytes = 3_000_000;
let lastActivityAt = Date.now(); let lastActivityAt = Date.now();
let activeCopy = {};
fetch(`${apiBase}/api/widget/config?siteKey=${encodeURIComponent(siteKey)}`) fetch(`${apiBase}/api/widget/config?${new URLSearchParams({
siteKey,
lang,
pageLang: document.documentElement.lang || "",
currentUrl: location.href,
browserLang: navigator.language || ""
})}`)
.then((res) => res.json()) .then((res) => res.json())
.then((config) => mount(config)) .then((config) => mount(config))
.catch(() => {}); .catch(() => {});
@@ -18,7 +25,8 @@
function mount(config) { function mount(config) {
const site = config.site; const site = config.site;
const settings = site.settings; const settings = site.settings;
const copy = localized(settings, lang); const copy = config.copy || localized(settings, config.language || lang);
activeCopy = copy;
const host = document.createElement("div"); const host = document.createElement("div");
host.id = "maalflows-widget"; host.id = "maalflows-widget";
document.body.append(host); document.body.append(host);
@@ -26,12 +34,12 @@
root.innerHTML = ` root.innerHTML = `
<style>${styles(settings)}</style> <style>${styles(settings)}</style>
<button class="mf-launcher ${launcherEffectClasses(settings, site.isOnline)}" type="button" aria-label="${copy.title}"> <button class="mf-launcher ${launcherEffectClasses(settings, site.isOnline)}" type="button" aria-label="${escapeHtml(copy.title)}">
<span class="mf-reply-pulse" aria-hidden="true"></span> <span class="mf-reply-pulse" aria-hidden="true"></span>
<span class="mf-launcher-icon"></span> <span class="mf-launcher-icon"></span>
<span class="mf-launcher-label">${escapeHtml(site.isOnline ? "Jsme online" : "Offline")}</span> <span class="mf-launcher-label">${escapeHtml(site.isOnline ? copy.onlineLabel : copy.offlineLabel)}</span>
<span class="mf-message-badge" hidden>0</span> <span class="mf-message-badge" hidden>0</span>
<span class="mf-message-label">Nova odpoved</span> <span class="mf-message-label">${escapeHtml(copy.newReplyLabel)}</span>
</button> </button>
<section class="mf-panel" aria-live="polite"> <section class="mf-panel" aria-live="polite">
<header> <header>
@@ -40,7 +48,7 @@
<button class="mf-close" type="button" aria-label="Close">×</button> <button class="mf-close" type="button" aria-label="Close">×</button>
</header> </header>
<div class="mf-messages"></div> <div class="mf-messages"></div>
<div class="mf-typing" hidden>Operator pise...</div> <div class="mf-typing" hidden>${escapeHtml(copy.typingLabel)}</div>
<form class="mf-form"> <form class="mf-form">
<div class="mf-composer"> <div class="mf-composer">
<textarea name="message" rows="3" placeholder="${escapeHtml(copy.placeholder)}"></textarea> <textarea name="message" rows="3" placeholder="${escapeHtml(copy.placeholder)}"></textarea>
@@ -55,7 +63,7 @@
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 17.5V6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5Zm3.3-1.2 2.9-3.4 2 2.4 2.7-3.2 3.1 4.2H7.3Zm1-6.8a1.7 1.7 0 1 0 0-3.4 1.7 1.7 0 0 0 0 3.4Z"></path> <path d="M4 17.5V6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5Zm3.3-1.2 2.9-3.4 2 2.4 2.7-3.2 3.1 4.2H7.3Zm1-6.8a1.7 1.7 0 1 0 0-3.4 1.7 1.7 0 0 0 0 3.4Z"></path>
</svg> </svg>
<span>Pridat fotku nebo pretahnout</span> <span>${escapeHtml(copy.dropzoneLabel)}</span>
</label> </label>
<div class="mf-attachment-list"></div> <div class="mf-attachment-list"></div>
<p class="mf-note" hidden></p> <p class="mf-note" hidden></p>
@@ -67,7 +75,9 @@
const panel = root.querySelector(".mf-panel"); const panel = root.querySelector(".mf-panel");
const launcher = root.querySelector(".mf-launcher"); const launcher = root.querySelector(".mf-launcher");
const close = root.querySelector(".mf-close"); const close = root.querySelector(".mf-close");
const { infoButton } = createHeaderActions(root, close); close.setAttribute("aria-label", copy.closeLabel || "Close");
close.title = copy.closeLabel || "Close";
const { infoButton } = createHeaderActions(root, close, copy);
const form = root.querySelector(".mf-form"); const form = root.querySelector(".mf-form");
const messages = root.querySelector(".mf-messages"); const messages = root.querySelector(".mf-messages");
const typing = root.querySelector(".mf-typing"); const typing = root.querySelector(".mf-typing");
@@ -276,24 +286,24 @@
} }
} }
function createHeaderActions(root, closeButton) { function createHeaderActions(root, closeButton, copy) {
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "mf-header-actions"; actions.className = "mf-header-actions";
const infoButton = document.createElement("button"); const infoButton = document.createElement("button");
infoButton.className = "mf-info"; infoButton.className = "mf-info";
infoButton.type = "button"; infoButton.type = "button";
infoButton.setAttribute("aria-label", "GDPR informace"); infoButton.setAttribute("aria-label", copy.infoTitle || "GDPR informace");
infoButton.title = "GDPR informace"; infoButton.title = copy.infoTitle || "GDPR informace";
infoButton.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 10h2v7h-2v-7Zm0-3h2v2h-2V7Zm1-5a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z"></path></svg>`; infoButton.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 10h2v7h-2v-7Zm0-3h2v2h-2V7Zm1-5a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z"></path></svg>`;
closeButton.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.7 8.7 12 14l5.3-5.3 1.4 1.4L12 16.8l-6.7-6.7 1.4-1.4Z"></path></svg>`; closeButton.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.7 8.7 12 14l5.3-5.3 1.4 1.4L12 16.8l-6.7-6.7 1.4-1.4Z"></path></svg>`;
closeButton.setAttribute("aria-label", "Zmensit chat"); closeButton.setAttribute("aria-label", copy.closeLabel || "Zmensit chat");
closeButton.title = "Zmensit chat"; closeButton.title = copy.closeLabel || "Zmensit chat";
closeButton.before(actions); closeButton.before(actions);
actions.append(infoButton, closeButton); actions.append(infoButton, closeButton);
const gdpr = document.createElement("p"); const gdpr = document.createElement("p");
gdpr.className = "mf-gdpr"; gdpr.className = "mf-gdpr";
gdpr.hidden = true; gdpr.hidden = true;
gdpr.textContent = "Odeslanim zpravy nam predavate udaje potrebne pro odpoved. Historii chatu uchovavame pro zakaznickou podporu."; gdpr.textContent = copy.infoText || "";
root.querySelector(".mf-form").prepend(gdpr); root.querySelector(".mf-form").prepend(gdpr);
return { infoButton }; return { infoButton };
} }
@@ -376,7 +386,7 @@
if (sender === "admin" && (senderName || "Operator") !== lastAdminName) { if (sender === "admin" && (senderName || "Operator") !== lastAdminName) {
const label = document.createElement("div"); const label = document.createElement("div");
label.className = "mf-operator-label"; label.className = "mf-operator-label";
label.textContent = `${senderName || "Operator"} odpovida`; label.textContent = `${senderName || "Operator"} ${activeCopy.operatorReplyLabel || "odpovida"}`;
messages.append(label); messages.append(label);
} }
const item = document.createElement("article"); const item = document.createElement("article");
@@ -476,16 +486,42 @@
} }
function localized(settings, language) { function localized(settings, language) {
if (settings.widgetCopy?.[language]) return settings.widgetCopy[language];
const pick = (obj) => obj?.[language] || obj?.en || obj?.cs || ""; const pick = (obj) => obj?.[language] || obj?.en || obj?.cs || "";
return { return {
title: pick(settings.title), title: pick(settings.title),
intro: pick(settings.intro), intro: pick(settings.intro),
offlineIntro: pick(settings.offlineIntro), offlineIntro: pick(settings.offlineIntro),
placeholder: pick(settings.placeholder), placeholder: pick(settings.placeholder),
sendLabel: pick(settings.sendLabel) sendLabel: pick(settings.sendLabel),
dropzoneLabel: "Pridat fotku nebo pretahnout",
onlineLabel: "Jsme online",
offlineLabel: "Offline",
newReplyLabel: "Nova odpoved",
typingLabel: "Operator pise...",
infoTitle: "GDPR informace",
infoText: "Zpravu a technicke udaje relace zpracujeme jen pro odpoved na vas dotaz.",
closeLabel: "Zmensit chat",
operatorReplyLabel: "odpovida"
}; };
} }
function detectPageLanguage(boot) {
const fromBoot = normalizeLanguage(boot.lang);
if (fromBoot) return fromBoot;
const fromUrl = normalizeLanguage(location.pathname.split("/").filter(Boolean)[0]);
if (fromUrl) return fromUrl;
const fromHtml = normalizeLanguage(document.documentElement.lang);
if (fromHtml) return fromHtml;
return normalizeLanguage(navigator.language) || "cs";
}
function normalizeLanguage(value) {
const text = String(value || "").trim().toLowerCase().replace("_", "-");
const match = text.match(/^[a-z]{2,3}/);
return match ? match[0] : "";
}
function launcherEffectClasses(settings, isOnline) { function launcherEffectClasses(settings, isOnline) {
if (!isOnline) return "mf-offline"; if (!isOnline) return "mf-offline";
const effects = settings.launcherEffects || {}; const effects = settings.launcherEffects || {};
+220 -8
View File
@@ -23,6 +23,12 @@ const ALLOWED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gi
const MAX_ATTACHMENT_BYTES = 3_000_000; const MAX_ATTACHMENT_BYTES = 3_000_000;
const ACTIVE_VISITOR_SECONDS = 90; const ACTIVE_VISITOR_SECONDS = 90;
const PRESENT_VISITOR_SECONDS = 300; const PRESENT_VISITOR_SECONDS = 300;
const WIDGET_COPY_FIELDS = ["title", "intro", "offlineIntro", "placeholder", "sendLabel", "dropzoneLabel", "onlineLabel", "offlineLabel", "newReplyLabel", "typingLabel", "infoTitle", "infoText", "closeLabel", "operatorReplyLabel"];
const EUROPEAN_WIDGET_LANGUAGES = new Set([
"sq", "be", "bs", "bg", "ca", "hr", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el",
"hu", "is", "ga", "it", "lv", "lt", "mk", "mt", "no", "pl", "pt", "ro", "ru", "sr", "sk",
"sl", "es", "sv", "tr", "uk"
]);
fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true });
fs.mkdirSync(UPLOAD_DIR, { recursive: true }); fs.mkdirSync(UPLOAD_DIR, { recursive: true });
@@ -286,12 +292,23 @@ function seedDefaults() {
} }
function defaultSettings() { function defaultSettings() {
const widgetCopy = {
cs: defaultWidgetCopy("cs"),
en: defaultWidgetCopy("en")
};
return { return {
title: { cs: "Potrebujete poradit?", en: "Need help?" }, widgetLanguage: {
intro: { cs: "Napiste nam. Odpovime co nejdrive.", en: "Message us and we will reply as soon as possible." }, mode: "auto",
offlineIntro: { cs: "Ted nejsme online, ale zpravu nam muzete nechat.", en: "We are offline, but you can leave us a message." }, defaultLanguage: "cs",
placeholder: { cs: "Napiste zpravu...", en: "Write a message..." }, fixedLanguage: "cs",
sendLabel: { cs: "Odeslat", en: "Send" }, fallbackLanguage: "cs"
},
widgetCopy,
title: { cs: widgetCopy.cs.title, en: widgetCopy.en.title },
intro: { cs: widgetCopy.cs.intro, en: widgetCopy.en.intro },
offlineIntro: { cs: widgetCopy.cs.offlineIntro, en: widgetCopy.en.offlineIntro },
placeholder: { cs: widgetCopy.cs.placeholder, en: widgetCopy.en.placeholder },
sendLabel: { cs: widgetCopy.cs.sendLabel, en: widgetCopy.en.sendLabel },
detailsLabel: { cs: "Kontaktni udaje", en: "Contact details" }, detailsLabel: { cs: "Kontaktni udaje", en: "Contact details" },
colors: { colors: {
brand: "#0f8f6f", brand: "#0f8f6f",
@@ -323,6 +340,43 @@ function defaultSettings() {
}; };
} }
function defaultWidgetCopy(language) {
if (language === "en") {
return {
title: "Need help?",
intro: "Message us and we will reply as soon as possible.",
offlineIntro: "We are offline, but you can leave us a message.",
placeholder: "Write a message...",
sendLabel: "Send",
dropzoneLabel: "Add a photo or drag it here",
onlineLabel: "We are online",
offlineLabel: "Offline",
newReplyLabel: "New reply",
typingLabel: "Operator is typing...",
infoTitle: "Privacy information",
infoText: "We process your message and technical session data only to answer your request.",
closeLabel: "Minimize chat",
operatorReplyLabel: "is replying"
};
}
return {
title: "Potrebujete poradit?",
intro: "Napiste nam. Odpovime co nejdrive.",
offlineIntro: "Ted nejsme online, ale zpravu nam muzete nechat.",
placeholder: "Napiste zpravu...",
sendLabel: "Odeslat",
dropzoneLabel: "Pridat fotku nebo pretahnout",
onlineLabel: "Jsme online",
offlineLabel: "Offline",
newReplyLabel: "Nova odpoved",
typingLabel: "Operator pise...",
infoTitle: "GDPR informace",
infoText: "Zpravu a technicke udaje relace zpracujeme jen pro odpoved na vas dotaz.",
closeLabel: "Zmensit chat",
operatorReplyLabel: "odpovida"
};
}
function configuredUsers() { function configuredUsers() {
const users = []; const users = [];
for (let i = 1; i < 50; i += 1) { for (let i = 1; i < 50; i += 1) {
@@ -634,11 +688,13 @@ function deleteConversation(res, url) {
return json(res, 200, { ok: true }); return json(res, 200, { ok: true });
} }
function widgetConfig(res, url) { async function widgetConfig(res, url) {
const siteKey = url.searchParams.get("siteKey") || process.env.DEFAULT_SITE_KEY || "9b-plus"; const siteKey = url.searchParams.get("siteKey") || process.env.DEFAULT_SITE_KEY || "9b-plus";
const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(siteKey); const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(siteKey);
if (!site) return json(res, 404, { error: "site_not_found" }); if (!site) return json(res, 404, { error: "site_not_found" });
return json(res, 200, { site: formatSite(site), publicBaseUrl: PUBLIC_BASE_URL }); const language = widgetLanguageForRequest(site, url);
const copy = await widgetCopyForLanguage(site, language);
return json(res, 200, { site: formatSite(getSiteById(site.id)), publicBaseUrl: PUBLIC_BASE_URL, language: copy.language, copy: copy.copy });
} }
async function visitorPing(req, res) { async function visitorPing(req, res) {
@@ -1017,6 +1073,8 @@ function outputTextFromResponse(data) {
function mergeSiteSettings(currentSettings, incomingSettings) { function mergeSiteSettings(currentSettings, incomingSettings) {
const settings = { ...currentSettings, ...incomingSettings }; const settings = { ...currentSettings, ...incomingSettings };
settings.widgetLanguage = normalizeWidgetLanguageSettings(settings.widgetLanguage || currentSettings.widgetLanguage || {});
settings.widgetCopy = normalizeWidgetCopy(settings.widgetCopy || currentSettings.widgetCopy || {}, settings);
const currentTranslations = currentSettings.translations || {}; const currentTranslations = currentSettings.translations || {};
const incomingTranslations = incomingSettings.translations || {}; const incomingTranslations = incomingSettings.translations || {};
settings.translations = { settings.translations = {
@@ -1035,6 +1093,58 @@ function mergeSiteSettings(currentSettings, incomingSettings) {
return settings; return settings;
} }
function normalizeWidgetLanguageSettings(value) {
const defaults = defaultSettings().widgetLanguage;
const settings = { ...defaults, ...(value || {}) };
const mode = settings.mode === "fixed" ? "fixed" : "auto";
return {
mode,
defaultLanguage: allowedWidgetLanguage(settings.defaultLanguage) || defaults.defaultLanguage,
fixedLanguage: allowedWidgetLanguage(settings.fixedLanguage) || defaults.fixedLanguage,
fallbackLanguage: allowedWidgetLanguage(settings.fallbackLanguage) || allowedWidgetLanguage(settings.defaultLanguage) || defaults.fallbackLanguage
};
}
function normalizeWidgetCopy(value, settings = {}) {
const copy = { ...(value || {}) };
const legacyLanguages = new Set([
...Object.keys(settings.title || {}),
...Object.keys(settings.intro || {}),
...Object.keys(settings.offlineIntro || {}),
...Object.keys(settings.placeholder || {}),
...Object.keys(settings.sendLabel || {})
]);
for (const language of legacyLanguages) {
const lang = allowedWidgetLanguage(language);
if (!lang) continue;
copy[lang] = {
...(copy[lang] || {}),
title: settings.title?.[lang],
intro: settings.intro?.[lang],
offlineIntro: settings.offlineIntro?.[lang],
placeholder: settings.placeholder?.[lang],
sendLabel: settings.sendLabel?.[lang]
};
}
copy.cs = normalizeWidgetCopyEntry(copy.cs || defaultWidgetCopy("cs"), "cs");
copy.en = normalizeWidgetCopyEntry(copy.en || defaultWidgetCopy("en"), "en");
for (const language of Object.keys(copy)) {
const lang = allowedWidgetLanguage(language);
if (!lang) delete copy[language];
else copy[lang] = normalizeWidgetCopyEntry(copy[language], lang);
}
return copy;
}
function normalizeWidgetCopyEntry(value, language) {
const defaults = defaultWidgetCopy(language === "en" ? "en" : "cs");
const copy = {};
for (const field of WIDGET_COPY_FIELDS) {
copy[field] = String(value?.[field] || defaults[field] || "").trim();
}
return copy;
}
function getTranslationSettings() { function getTranslationSettings() {
const site = getDefaultSite(); const site = getDefaultSite();
const settings = parseJson(site.settings_json, defaultSettings()); const settings = parseJson(site.settings_json, defaultSettings());
@@ -1047,6 +1157,103 @@ function getTranslationSettings() {
}; };
} }
function widgetLanguageForRequest(site, url) {
const settings = parseJson(site.settings_json, defaultSettings());
const languageSettings = normalizeWidgetLanguageSettings(settings.widgetLanguage);
if (languageSettings.mode === "fixed") return languageSettings.fixedLanguage;
return allowedWidgetLanguage(url.searchParams.get("lang"))
|| allowedWidgetLanguage(url.searchParams.get("pageLang"))
|| languageFromUrl(url.searchParams.get("currentUrl"))
|| allowedWidgetLanguage(url.searchParams.get("browserLang"))
|| languageSettings.fallbackLanguage;
}
async function widgetCopyForLanguage(site, requestedLanguage) {
const settings = parseJson(site.settings_json, defaultSettings());
settings.widgetLanguage = normalizeWidgetLanguageSettings(settings.widgetLanguage);
settings.widgetCopy = normalizeWidgetCopy(settings.widgetCopy || {}, settings);
const language = allowedWidgetLanguage(requestedLanguage) || settings.widgetLanguage.fallbackLanguage;
if (settings.widgetCopy[language]) return { language, copy: settings.widgetCopy[language] };
const fallbackLanguage = settings.widgetCopy[settings.widgetLanguage.fallbackLanguage]
? settings.widgetLanguage.fallbackLanguage
: settings.widgetLanguage.defaultLanguage;
const fallbackCopy = settings.widgetCopy[fallbackLanguage] || defaultWidgetCopy("cs");
const translated = await translateWidgetCopyOnce(fallbackCopy, fallbackLanguage, language);
if (!translated) return { language: fallbackLanguage, copy: fallbackCopy };
settings.widgetCopy[language] = translated;
syncLegacyWidgetCopy(settings);
db.prepare("UPDATE sites SET settings_json = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(JSON.stringify(settings), site.id);
return { language, copy: translated };
}
async function translateWidgetCopyOnce(sourceCopy, sourceLanguage, targetLanguage) {
const settings = getTranslationSettings();
if (!settings.enabled || !settings.openaiApiKey || !targetLanguage || sourceLanguage === targetLanguage) return null;
try {
const response = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
"Authorization": `Bearer ${settings.openaiApiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: settings.model,
input: [
{
role: "system",
content: "Translate UI copy for a live chat widget. Return only compact JSON with the same keys. Preserve brand names and meaning. Keep labels short."
},
{
role: "user",
content: JSON.stringify({ sourceLanguage, targetLanguage, copy: sourceCopy })
}
]
})
});
if (!response.ok) {
const openAiError = await openAiErrorFromResponse(response);
throw new Error(openAiError.message);
}
const data = await response.json();
return normalizeWidgetCopyEntry(parseJsonBlock(outputTextFromResponse(data)), targetLanguage);
} catch (error) {
console.error("widget_copy_translation_failed", targetLanguage, error.message);
return null;
}
}
function parseJsonBlock(value) {
const text = String(value || "").trim().replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
return JSON.parse(text);
}
function syncLegacyWidgetCopy(settings) {
for (const language of Object.keys(settings.widgetCopy || {})) {
const copy = settings.widgetCopy[language];
settings.title = { ...(settings.title || {}), [language]: copy.title };
settings.intro = { ...(settings.intro || {}), [language]: copy.intro };
settings.offlineIntro = { ...(settings.offlineIntro || {}), [language]: copy.offlineIntro };
settings.placeholder = { ...(settings.placeholder || {}), [language]: copy.placeholder };
settings.sendLabel = { ...(settings.sendLabel || {}), [language]: copy.sendLabel };
}
}
function languageFromUrl(value) {
try {
return allowedWidgetLanguage(new URL(value).pathname.split("/").filter(Boolean)[0]);
} catch {
return "";
}
}
function allowedWidgetLanguage(value) {
const language = normalizeLanguage(value);
return EUROPEAN_WIDGET_LANGUAGES.has(language) ? language : "";
}
function normalizeOpenAiApiKey(value) { function normalizeOpenAiApiKey(value) {
const text = String(value || "").trim(); const text = String(value || "").trim();
if (!text || isMaskedApiKey(text)) return ""; if (!text || isMaskedApiKey(text)) return "";
@@ -1283,6 +1490,8 @@ function formatSite(site) {
const settings = parseJson(site.settings_json, defaultSettings()); const settings = parseJson(site.settings_json, defaultSettings());
settings.notifications = { ...defaultSettings().notifications, ...(settings.notifications || {}) }; settings.notifications = { ...defaultSettings().notifications, ...(settings.notifications || {}) };
settings.launcherEffects = { ...defaultSettings().launcherEffects, ...(settings.launcherEffects || {}) }; settings.launcherEffects = { ...defaultSettings().launcherEffects, ...(settings.launcherEffects || {}) };
settings.widgetLanguage = normalizeWidgetLanguageSettings(settings.widgetLanguage);
settings.widgetCopy = normalizeWidgetCopy(settings.widgetCopy || {}, settings);
settings.translations = { ...defaultSettings().translations, ...(settings.translations || {}) }; settings.translations = { ...defaultSettings().translations, ...(settings.translations || {}) };
const storedOpenAiKey = settings.translations.openaiApiKey || OPENAI_API_KEY; const storedOpenAiKey = settings.translations.openaiApiKey || OPENAI_API_KEY;
settings.translations.hasOpenaiApiKey = Boolean(storedOpenAiKey); settings.translations.hasOpenaiApiKey = Boolean(storedOpenAiKey);
@@ -1302,7 +1511,6 @@ function snippetFor(site) {
return `<script> return `<script>
window.MaalFlows = { window.MaalFlows = {
siteKey: "${site.site_key}", siteKey: "${site.site_key}",
lang: "cs",
apiBase: "${PUBLIC_BASE_URL}" apiBase: "${PUBLIC_BASE_URL}"
}; };
</script> </script>
@@ -1313,6 +1521,10 @@ function getDefaultSite() {
return db.prepare("SELECT * FROM sites ORDER BY id LIMIT 1").get(); return db.prepare("SELECT * FROM sites ORDER BY id LIMIT 1").get();
} }
function getSiteById(id) {
return db.prepare("SELECT * FROM sites WHERE id = ?").get(id);
}
function findConversation(publicId) { function findConversation(publicId) {
return db.prepare(` return db.prepare(`
SELECT c.*, s.site_key, vp.display_name AS visitor_display_name, SELECT c.*, s.site_key, vp.display_name AS visitor_display_name,