From c6dfd75b24c4d861ec3ce393d2b46e24b022020a Mon Sep 17 00:00:00 2001 From: rajchales-monito Date: Thu, 30 Jul 2026 09:32:01 +0200 Subject: [PATCH] Sync widget copy translations on save --- public/admin.css | 30 +++++++++++++++++++ public/admin.html | 5 +++- public/admin.js | 51 +++++++++++++++++++++++-------- server.js | 76 ++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 138 insertions(+), 24 deletions(-) diff --git a/public/admin.css b/public/admin.css index 963c367..e5fa196 100644 --- a/public/admin.css +++ b/public/admin.css @@ -244,6 +244,36 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } display: grid; align-items: stretch; } +.settings-actions { + display: flex; + align-items: center; + gap: 10px; +} +.settings-save-status { + min-height: 18px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} +.settings-save-status.saving::before, +.settings-save-status.translating::before { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + margin-right: 6px; + border: 2px solid rgba(15, 143, 111, .25); + border-top-color: var(--brand); + border-radius: 50%; + vertical-align: -2px; + animation: mfSpin .8s linear infinite; +} +.settings-save-status.saved { color: var(--brand); } +.settings-save-status.warning { color: #b7791f; } +.settings-save-status.error { color: #b42318; } +@keyframes mfSpin { + to { transform: rotate(360deg); } +} .ai-help { margin: 0; color: var(--muted); diff --git a/public/admin.html b/public/admin.html index ab8cb22..3244866 100644 --- a/public/admin.html +++ b/public/admin.html @@ -164,7 +164,10 @@ - +
+ + +

GTM snippet


diff --git a/public/admin.js b/public/admin.js
index b725191..4eb848f 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -128,6 +128,8 @@ $("#onlineToggle").addEventListener("change", async () => {
 
 $("#settingsForm").addEventListener("submit", async (event) => {
   event.preventDefault();
+  const submitButton = event.currentTarget.querySelector("button[type='submit']");
+  setSettingsSaveStatus("saving", "Ukládám...");
   const form = new FormData(event.currentTarget);
   const settings = structuredClone(state.site.settings);
   const defaultLanguage = form.get("defaultLanguage") || "cs";
@@ -148,10 +150,10 @@ $("#settingsForm").addEventListener("submit", async (event) => {
     onlineLabel: form.get("copyOnlineLabel") || "",
     offlineLabel: settings.widgetCopy[defaultLanguage]?.offlineLabel || "Offline",
     newReplyLabel: form.get("copyNewReplyLabel") || "",
-    typingLabel: settings.widgetCopy[defaultLanguage]?.typingLabel || "Operator pise...",
+    typingLabel: settings.widgetCopy[defaultLanguage]?.typingLabel || "Operátor píše...",
     infoTitle: settings.widgetCopy[defaultLanguage]?.infoTitle || "GDPR informace",
     infoText: form.get("copyInfoText") || "",
-    closeLabel: settings.widgetCopy[defaultLanguage]?.closeLabel || "Zmensit chat",
+    closeLabel: settings.widgetCopy[defaultLanguage]?.closeLabel || "Zmenšit chat",
     operatorReplyLabel: form.get("copyOperatorReplyLabel") || ""
   };
   syncLegacyWidgetCopy(settings);
@@ -173,8 +175,25 @@ $("#settingsForm").addEventListener("submit", async (event) => {
   settings.notifications.badgeOnAdminReply = form.get("messageBadge") === "on";
   settings.notifications.labelOnAdminReply = form.get("messageLabel") === "on";
   settings.notifications.wiggleOnAdminReply = form.get("messageWiggle") === "on";
-  await saveSite({ settings, isOnline: state.site.isOnline });
-  $("#settingsPanel").classList.add("hidden");
+  submitButton.disabled = true;
+  try {
+    setSettingsSaveStatus("translating", "Překládám...");
+    const data = await saveSite({ settings, isOnline: state.site.isOnline });
+    renderWidgetCopyFields(state.site.settings.widgetLanguage?.defaultLanguage || defaultLanguage);
+    const sync = data.widgetCopySync;
+    if (sync?.errors?.length) {
+      const failedLanguages = [...new Set(sync.errors.map((item) => item.language?.toUpperCase()).filter(Boolean))];
+      setSettingsSaveStatus("warning", `Uloženo, překlad selhal: ${failedLanguages.join(", ") || "AI"}`);
+      return;
+    }
+    if (sync?.translated) setSettingsSaveStatus("saved", `✓ Uloženo a přeloženo`);
+    else setSettingsSaveStatus("saved", "✓ Uloženo");
+    setTimeout(() => $("#settingsPanel").classList.add("hidden"), 700);
+  } catch (error) {
+    setSettingsSaveStatus("error", "Akce se nepodařila.");
+  } finally {
+    submitButton.disabled = false;
+  }
 });
 
 $("#replyForm").addEventListener("submit", async (event) => {
@@ -493,16 +512,16 @@ function defaultWidgetCopy(language) {
     };
   }
   return {
-    title: "Potrebujete poradit?",
-    intro: "Napiste nam. Odpovime co nejdrive.",
-    offlineIntro: "Ted nejsme online, ale zpravu nam muzete nechat.",
-    placeholder: "Napiste zpravu...",
+    title: "Potřebujete poradit?",
+    intro: "Napište nám. Odpovíme co nejdříve.",
+    offlineIntro: "Teď nejsme online, ale zprávu nám můžete nechat.",
+    placeholder: "Napište zprávu...",
     sendLabel: "Odeslat",
-    dropzoneLabel: "Pridat fotku nebo pretahnout",
+    dropzoneLabel: "Přidat fotku nebo přetáhnout",
     onlineLabel: "Jsme online",
-    newReplyLabel: "Nova odpoved",
-    infoText: "Zpravu a technicke udaje relace zpracujeme jen pro odpoved na vas dotaz.",
-    operatorReplyLabel: "odpovida"
+    newReplyLabel: "Nová odpověď",
+    infoText: "Zprávu a technické údaje relace zpracujeme jen pro odpověď na váš dotaz.",
+    operatorReplyLabel: "odpovídá"
   };
 }
 
@@ -1230,6 +1249,14 @@ async function saveSite(patch) {
   state.site = data.site;
   state.snippet = data.snippet;
   renderSite();
+  return data;
+}
+
+function setSettingsSaveStatus(type, text) {
+  const status = $("#settingsSaveStatus");
+  if (!status) return;
+  status.className = `settings-save-status ${type || ""}`.trim();
+  status.textContent = text || "";
 }
 
 function notify() {
diff --git a/server.js b/server.js
index ed1c390..3525046 100644
--- a/server.js
+++ b/server.js
@@ -358,20 +358,20 @@ function defaultWidgetCopy(language) {
     };
   }
   return {
-    title: "Potrebujete poradit?",
-    intro: "Napiste nam. Odpovime co nejdrive.",
-    offlineIntro: "Ted nejsme online, ale zpravu nam muzete nechat.",
-    placeholder: "Napiste zpravu...",
+    title: "Potřebujete poradit?",
+    intro: "Napište nám. Odpovíme co nejdříve.",
+    offlineIntro: "Teď nejsme online, ale zprávu nám můžete nechat.",
+    placeholder: "Napište zprávu...",
     sendLabel: "Odeslat",
-    dropzoneLabel: "Pridat fotku nebo pretahnout",
+    dropzoneLabel: "Přidat fotku nebo přetáhnout",
     onlineLabel: "Jsme online",
     offlineLabel: "Offline",
-    newReplyLabel: "Nova odpoved",
-    typingLabel: "Operator pise...",
+    newReplyLabel: "Nová odpověď",
+    typingLabel: "Operátor píše...",
     infoTitle: "GDPR informace",
-    infoText: "Zpravu a technicke udaje relace zpracujeme jen pro odpoved na vas dotaz.",
-    closeLabel: "Zmensit chat",
-    operatorReplyLabel: "odpovida"
+    infoText: "Zprávu a technické údaje relace zpracujeme jen pro odpověď na váš dotaz.",
+    closeLabel: "Zmenšit chat",
+    operatorReplyLabel: "odpovídá"
   };
 }
 
@@ -522,6 +522,8 @@ async function updateSite(req, res) {
   const currentSettings = parseJson(site.settings_json, defaultSettings());
   const incomingSettings = body.settings || {};
   const settings = mergeSiteSettings(currentSettings, incomingSettings);
+  const widgetCopySync = await syncChangedWidgetCopyTranslations(currentSettings, settings);
+  syncLegacyWidgetCopy(settings);
   db.prepare(`
     UPDATE sites
     SET is_online = ?, settings_json = ?, updated_at = CURRENT_TIMESTAMP
@@ -529,7 +531,7 @@ async function updateSite(req, res) {
   `).run(body.isOnline ? 1 : 0, JSON.stringify(settings), site.id);
   const updated = getDefaultSite();
   publish("admin", { type: "site:update", site: formatSite(updated) });
-  return json(res, 200, { site: formatSite(updated), snippet: snippetFor(updated) });
+  return json(res, 200, { site: formatSite(updated), snippet: snippetFor(updated), widgetCopySync });
 }
 
 function adminUsers(res) {
@@ -1205,6 +1207,58 @@ async function translateWidgetCopyOnce(sourceCopy, sourceLanguage, targetLanguag
   }
 }
 
+async function syncChangedWidgetCopyTranslations(previousSettings, nextSettings) {
+  const languageSettings = normalizeWidgetLanguageSettings(nextSettings.widgetLanguage);
+  const defaultLanguage = languageSettings.defaultLanguage;
+  const previousCopy = normalizeWidgetCopy(previousSettings.widgetCopy || {}, previousSettings);
+  const nextCopy = normalizeWidgetCopy(nextSettings.widgetCopy || {}, nextSettings);
+  const previousDefaultCopy = previousCopy[defaultLanguage] || {};
+  const nextDefaultCopy = nextCopy[defaultLanguage] || {};
+  const changedFields = WIDGET_COPY_FIELDS.filter((field) =>
+    String(previousDefaultCopy[field] || "").trim() !== String(nextDefaultCopy[field] || "").trim()
+  );
+  if (!changedFields.length) return { changedFields: [], languages: [], translated: false, errors: [] };
+
+  const translationSettings = {
+    ...defaultSettings().translations,
+    ...(nextSettings.translations || {})
+  };
+  const apiKey = normalizeOpenAiApiKey(translationSettings.openaiApiKey) || OPENAI_API_KEY;
+  const model = String(translationSettings.model || OPENAI_TRANSLATION_MODEL).trim() || OPENAI_TRANSLATION_MODEL;
+  const languages = Object.keys(nextCopy)
+    .map(allowedWidgetLanguage)
+    .filter((language, index, list) => language && language !== defaultLanguage && list.indexOf(language) === index);
+
+  if (!languages.length) return { changedFields, languages: [], translated: false, errors: [] };
+  if (!translationSettings.enabled || !apiKey) {
+    return { changedFields, languages, translated: false, errors: languages.map((language) => ({ language, error: "missing_ai_settings" })) };
+  }
+
+  nextSettings.widgetCopy = nextCopy;
+  const errors = [];
+  let translated = 0;
+  for (const language of languages) {
+    nextSettings.widgetCopy[language] = normalizeWidgetCopyEntry(nextSettings.widgetCopy[language] || {}, language);
+    for (const field of changedFields) {
+      const value = String(nextDefaultCopy[field] || "").trim();
+      if (!value) {
+        nextSettings.widgetCopy[language][field] = "";
+        translated += 1;
+        continue;
+      }
+      try {
+        const result = await translateText(value, language, model, apiKey);
+        nextSettings.widgetCopy[language][field] = String(result.translatedText || value).trim();
+        translated += 1;
+      } catch (error) {
+        console.error("widget_copy_sync_failed", language, field, error.message);
+        errors.push({ language, field, error: classifyOpenAiError(error.message), message: error.message });
+      }
+    }
+  }
+  return { changedFields, languages, translated: translated > 0, errors };
+}
+
 function widgetCopyLooksUntranslated(sourceCopy, translatedCopy) {
   const importantFields = ["title", "intro", "offlineIntro", "placeholder", "sendLabel"];
   return importantFields.every((field) => String(sourceCopy[field] || "").trim() === String(translatedCopy[field] || "").trim());