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());