Add lazy widget language localization
This commit is contained in:
@@ -23,6 +23,12 @@ const ALLOWED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gi
|
||||
const MAX_ATTACHMENT_BYTES = 3_000_000;
|
||||
const ACTIVE_VISITOR_SECONDS = 90;
|
||||
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(UPLOAD_DIR, { recursive: true });
|
||||
@@ -286,12 +292,23 @@ function seedDefaults() {
|
||||
}
|
||||
|
||||
function defaultSettings() {
|
||||
const widgetCopy = {
|
||||
cs: defaultWidgetCopy("cs"),
|
||||
en: defaultWidgetCopy("en")
|
||||
};
|
||||
return {
|
||||
title: { cs: "Potrebujete poradit?", en: "Need help?" },
|
||||
intro: { cs: "Napiste nam. Odpovime co nejdrive.", en: "Message us and we will reply as soon as possible." },
|
||||
offlineIntro: { cs: "Ted nejsme online, ale zpravu nam muzete nechat.", en: "We are offline, but you can leave us a message." },
|
||||
placeholder: { cs: "Napiste zpravu...", en: "Write a message..." },
|
||||
sendLabel: { cs: "Odeslat", en: "Send" },
|
||||
widgetLanguage: {
|
||||
mode: "auto",
|
||||
defaultLanguage: "cs",
|
||||
fixedLanguage: "cs",
|
||||
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" },
|
||||
colors: {
|
||||
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() {
|
||||
const users = [];
|
||||
for (let i = 1; i < 50; i += 1) {
|
||||
@@ -634,11 +688,13 @@ function deleteConversation(res, url) {
|
||||
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 site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(siteKey);
|
||||
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) {
|
||||
@@ -1017,6 +1073,8 @@ function outputTextFromResponse(data) {
|
||||
|
||||
function mergeSiteSettings(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 incomingTranslations = incomingSettings.translations || {};
|
||||
settings.translations = {
|
||||
@@ -1035,6 +1093,58 @@ function mergeSiteSettings(currentSettings, incomingSettings) {
|
||||
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() {
|
||||
const site = getDefaultSite();
|
||||
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) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text || isMaskedApiKey(text)) return "";
|
||||
@@ -1283,6 +1490,8 @@ function formatSite(site) {
|
||||
const settings = parseJson(site.settings_json, defaultSettings());
|
||||
settings.notifications = { ...defaultSettings().notifications, ...(settings.notifications || {}) };
|
||||
settings.launcherEffects = { ...defaultSettings().launcherEffects, ...(settings.launcherEffects || {}) };
|
||||
settings.widgetLanguage = normalizeWidgetLanguageSettings(settings.widgetLanguage);
|
||||
settings.widgetCopy = normalizeWidgetCopy(settings.widgetCopy || {}, settings);
|
||||
settings.translations = { ...defaultSettings().translations, ...(settings.translations || {}) };
|
||||
const storedOpenAiKey = settings.translations.openaiApiKey || OPENAI_API_KEY;
|
||||
settings.translations.hasOpenaiApiKey = Boolean(storedOpenAiKey);
|
||||
@@ -1302,7 +1511,6 @@ function snippetFor(site) {
|
||||
return `<script>
|
||||
window.MaalFlows = {
|
||||
siteKey: "${site.site_key}",
|
||||
lang: "cs",
|
||||
apiBase: "${PUBLIC_BASE_URL}"
|
||||
};
|
||||
</script>
|
||||
@@ -1313,6 +1521,10 @@ function getDefaultSite() {
|
||||
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) {
|
||||
return db.prepare(`
|
||||
SELECT c.*, s.site_key, vp.display_name AS visitor_display_name,
|
||||
|
||||
Reference in New Issue
Block a user