Add lazy widget language localization
This commit is contained in:
+12
-1
@@ -226,9 +226,20 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
||||
margin: 0;
|
||||
}
|
||||
.effect-settings .full-row,
|
||||
.effect-settings .ai-help {
|
||||
.effect-settings .ai-help,
|
||||
.widget-language-settings small {
|
||||
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 {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
|
||||
+26
-4
@@ -114,10 +114,32 @@
|
||||
</div>
|
||||
<section class="settings-tab-panel active" data-settings-panel="widget">
|
||||
<form id="settingsForm">
|
||||
<label>Titulek CS <input name="titleCs"></label>
|
||||
<label>Intro CS <textarea name="introCs" rows="2"></textarea></label>
|
||||
<label>Titulek EN <input name="titleEn"></label>
|
||||
<label>Intro EN <textarea name="introEn" rows="2"></textarea></label>
|
||||
<fieldset class="effect-settings widget-language-settings">
|
||||
<legend>Jazyk widgetu</legend>
|
||||
<label>Rezim
|
||||
<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
|
||||
<span class="color-row">
|
||||
<input name="brand" type="color">
|
||||
|
||||
+128
-8
@@ -24,6 +24,15 @@ const state = {
|
||||
};
|
||||
|
||||
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();
|
||||
renderAdminSoundControls();
|
||||
@@ -104,6 +113,9 @@ $("#settingsForm").brandHex.addEventListener("input", (event) => {
|
||||
const color = normalizeHexColor(event.target.value);
|
||||
if (color) $("#settingsForm").brand.value = color;
|
||||
});
|
||||
$("#settingsForm").defaultLanguage.addEventListener("change", () => {
|
||||
renderWidgetCopyFields($("#settingsForm").defaultLanguage.value);
|
||||
});
|
||||
|
||||
window.addEventListener("focus", syncAttentionWithUnread);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
@@ -118,10 +130,33 @@ $("#settingsForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const settings = structuredClone(state.site.settings);
|
||||
settings.title.cs = form.get("titleCs");
|
||||
settings.intro.cs = form.get("introCs");
|
||||
settings.title.en = form.get("titleEn");
|
||||
settings.intro.en = form.get("introEn");
|
||||
const defaultLanguage = form.get("defaultLanguage") || "cs";
|
||||
settings.widgetLanguage = {
|
||||
...(settings.widgetLanguage || {}),
|
||||
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.desktop.side = form.get("desktopSide");
|
||||
settings.desktop.bottomPx = Number(form.get("desktopBottom"));
|
||||
@@ -378,10 +413,8 @@ function renderSite() {
|
||||
$("#snippet").textContent = state.snippet;
|
||||
const s = state.site.settings;
|
||||
const form = $("#settingsForm");
|
||||
form.titleCs.value = s.title.cs || "";
|
||||
form.introCs.value = s.intro.cs || "";
|
||||
form.titleEn.value = s.title.en || "";
|
||||
form.introEn.value = s.intro.en || "";
|
||||
renderWidgetLanguageOptions(form, s);
|
||||
renderWidgetCopyFields(s.widgetLanguage?.defaultLanguage || "cs");
|
||||
form.brand.value = normalizeHexColor(s.colors.brand) || "#0f8f6f";
|
||||
form.brandHex.value = form.brand.value;
|
||||
form.desktopSide.value = s.desktop.side || "right";
|
||||
@@ -407,6 +440,93 @@ function renderSite() {
|
||||
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() {
|
||||
if (!state.site) return;
|
||||
setOpenAiStatus("Ukladam AI nastaveni...");
|
||||
|
||||
+53
-17
@@ -2,15 +2,22 @@
|
||||
const boot = window.MaalFlows || {};
|
||||
const apiBase = (boot.apiBase || "").replace(/\/$/, "") || new URL(document.currentScript.src).origin;
|
||||
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 pendingStorageKey = `${storageKey}:pending`;
|
||||
const session = loadSession();
|
||||
const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
|
||||
const maxAttachmentBytes = 3_000_000;
|
||||
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((config) => mount(config))
|
||||
.catch(() => {});
|
||||
@@ -18,7 +25,8 @@
|
||||
function mount(config) {
|
||||
const site = config.site;
|
||||
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");
|
||||
host.id = "maalflows-widget";
|
||||
document.body.append(host);
|
||||
@@ -26,12 +34,12 @@
|
||||
|
||||
root.innerHTML = `
|
||||
<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-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-label">Nova odpoved</span>
|
||||
<span class="mf-message-label">${escapeHtml(copy.newReplyLabel)}</span>
|
||||
</button>
|
||||
<section class="mf-panel" aria-live="polite">
|
||||
<header>
|
||||
@@ -40,7 +48,7 @@
|
||||
<button class="mf-close" type="button" aria-label="Close">×</button>
|
||||
</header>
|
||||
<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">
|
||||
<div class="mf-composer">
|
||||
<textarea name="message" rows="3" placeholder="${escapeHtml(copy.placeholder)}"></textarea>
|
||||
@@ -55,7 +63,7 @@
|
||||
<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>
|
||||
</svg>
|
||||
<span>Pridat fotku nebo pretahnout</span>
|
||||
<span>${escapeHtml(copy.dropzoneLabel)}</span>
|
||||
</label>
|
||||
<div class="mf-attachment-list"></div>
|
||||
<p class="mf-note" hidden></p>
|
||||
@@ -67,7 +75,9 @@
|
||||
const panel = root.querySelector(".mf-panel");
|
||||
const launcher = root.querySelector(".mf-launcher");
|
||||
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 messages = root.querySelector(".mf-messages");
|
||||
const typing = root.querySelector(".mf-typing");
|
||||
@@ -276,24 +286,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
function createHeaderActions(root, closeButton) {
|
||||
function createHeaderActions(root, closeButton, copy) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "mf-header-actions";
|
||||
const infoButton = document.createElement("button");
|
||||
infoButton.className = "mf-info";
|
||||
infoButton.type = "button";
|
||||
infoButton.setAttribute("aria-label", "GDPR informace");
|
||||
infoButton.title = "GDPR informace";
|
||||
infoButton.setAttribute("aria-label", copy.infoTitle || "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>`;
|
||||
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.title = "Zmensit chat";
|
||||
closeButton.setAttribute("aria-label", copy.closeLabel || "Zmensit chat");
|
||||
closeButton.title = copy.closeLabel || "Zmensit chat";
|
||||
closeButton.before(actions);
|
||||
actions.append(infoButton, closeButton);
|
||||
const gdpr = document.createElement("p");
|
||||
gdpr.className = "mf-gdpr";
|
||||
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);
|
||||
return { infoButton };
|
||||
}
|
||||
@@ -376,7 +386,7 @@
|
||||
if (sender === "admin" && (senderName || "Operator") !== lastAdminName) {
|
||||
const label = document.createElement("div");
|
||||
label.className = "mf-operator-label";
|
||||
label.textContent = `${senderName || "Operator"} odpovida`;
|
||||
label.textContent = `${senderName || "Operator"} ${activeCopy.operatorReplyLabel || "odpovida"}`;
|
||||
messages.append(label);
|
||||
}
|
||||
const item = document.createElement("article");
|
||||
@@ -476,16 +486,42 @@
|
||||
}
|
||||
|
||||
function localized(settings, language) {
|
||||
if (settings.widgetCopy?.[language]) return settings.widgetCopy[language];
|
||||
const pick = (obj) => obj?.[language] || obj?.en || obj?.cs || "";
|
||||
return {
|
||||
title: pick(settings.title),
|
||||
intro: pick(settings.intro),
|
||||
offlineIntro: pick(settings.offlineIntro),
|
||||
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) {
|
||||
if (!isOnline) return "mf-offline";
|
||||
const effects = settings.launcherEffects || {};
|
||||
|
||||
Reference in New Issue
Block a user