diff --git a/frontend/src/ContactOverlay.vue b/frontend/src/ContactOverlay.vue index 38b00d9..55065a2 100644 --- a/frontend/src/ContactOverlay.vue +++ b/frontend/src/ContactOverlay.vue @@ -17,6 +17,13 @@ const emit = defineEmits(['send', 'close']) const admin = computed(() => props.state.mode === 'admin') +// The visitor dialog's title is the localized site contact label (the +// {contact:} buttons' default label); the mailto link carries the +// localized subject. +const mailto = computed( + () => `mailto:${props.state.email}?subject=${encodeURIComponent(props.state.subject)}` +) + // Escape closes, like the paskia dialogs whose backdrop we share. function onKey(ev) { if (ev.key === 'Escape' && props.state.open) emit('close') @@ -31,10 +38,11 @@ onUnmounted(() => removeEventListener('keydown', onKey)) class="contact-panel" :class="{ admin }" role="dialog" - :aria-label="admin ? 'Messages' : 'Contact us'" + :aria-label="admin ? 'Messages' : state.label" >
-

{{ admin ? 'Messages' : 'Contact us' }}

+

{{ admin ? 'Messages' : state.label }}

+ {{ state.email }}
@@ -80,11 +88,24 @@ onUnmounted(() => removeEventListener('keydown', onKey)) .contact-panel header { display: flex; align-items: center; + gap: 0.6rem; } .contact-panel h2 { margin: 0; font-size: 1.1rem; } +.contact-panel .mailto { + color: var(--muted); + font-size: 0.85rem; + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.contact-panel .mailto:hover { + color: var(--text, CanvasText); + text-decoration: underline; +} .contact-panel .close { margin-left: auto; padding: 0 0.3rem; diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue index 4d871e8..b07da89 100644 --- a/frontend/src/SiteEditor.vue +++ b/frontend/src/SiteEditor.vue @@ -57,6 +57,69 @@ async function errorDetail(res) { // empty brand removes the header link and the title suffix entirely. const brand = ref('') const theme = ref('') + +// --- Messaging (contact) --------------------------------------------------- +// Site-wide contact settings: the enabled toggle and mailto address are +// global; the label (the {contact:} buttons' default and the visitor +// dialog's title) and the email subject are per-language strings — the +// selector switches the two text fields between the original and a +// translation override (stored in the chunk-keyed trans store; auto +// translators fill the same store, so this is the manual-override UI). +const contactEnabled = ref(true) +const contactEmail = ref('') +const contactLabel = ref('') +const contactSubject = ref('') +const contactLabelOrig = ref('') +const contactSubjectOrig = ref('') +const contactLang = ref('') // '' = the original strings +const primaryLang = ref('en') +const translateLangs = ref([]) +const translating = computed(() => contactLang.value && contactLang.value !== primaryLang.value) + +async function onContactLangChange() { + if (!translating.value) { + contactLabel.value = contactLabelOrig.value + contactSubject.value = contactSubjectOrig.value + return + } + try { + const s = await apiJson(`/_api/settings?lang=${contactLang.value}`) + contactLabel.value = s.contact_label_tr || '' + contactSubject.value = s.contact_subject_tr || '' + } catch { /* keep empty */ } +} + +function onContactInput() { + // Capture the language being edited: a debounced save must not land in + // whatever language happens to be selected when the timer fires. + const lang = contactLang.value + if (!lang || lang === primaryLang.value) { + contactLabelOrig.value = contactLabel.value + contactSubjectOrig.value = contactSubject.value + } + debounce('contact', () => saveContact(lang, contactLabel.value, contactSubject.value)) +} + +async function saveContact(lang = contactLang.value, label = '', subject = '') { + if (lang && lang !== primaryLang.value) { + // Translation override: only the label/subject apply (empty = drop). + const res = await apiFetch(`/_api/settings?lang=${lang}`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + brand: brand.value, + theme: theme.value, + custom_css: customCss.value, + contact_label: label, + contact_subject: subject, + }), + }) + saveError.value = res.ok ? '' : '⚠️ changes could not be saved' + if (res.ok) dropPageCache() + return + } + saveSettings() +} // Theme options come from the backend (theme folders on disk, see GET // /_api/settings), so added themes need no frontend changes. const themeOptions = ref([{ value: '', label: '☀️ none' }]) @@ -88,6 +151,16 @@ async function loadSettings() { ] transition.value = s.transition || 'cube' transitionOptions.value = s.transitions || [] + contactEnabled.value = s.contact_enabled !== false + contactEmail.value = s.contact_email || '' + contactLabelOrig.value = s.contact_label || '' + contactSubjectOrig.value = s.contact_subject || '' + if (!translating.value) { + contactLabel.value = contactLabelOrig.value + contactSubject.value = contactSubjectOrig.value + } + primaryLang.value = s.primary_lang || 'en' + translateLangs.value = s.translate_langs || [] fontOptions.value = [ ...BASE_FONT_OPTIONS, ...(s.fonts || []).map((f) => ({ @@ -255,6 +328,12 @@ async function saveSettings(opts = {}) { custom_css: customCss.value, brand_html: brandHtml.value, transition: transition.value, + contact_enabled: contactEnabled.value, + contact_email: contactEmail.value, + // The originals: while a translation override is being edited the + // input refs hold the override, not the original. + contact_label: contactLabelOrig.value, + contact_subject: contactSubjectOrig.value, ...opts, }), }) @@ -655,6 +734,63 @@ onUnmounted(() => { +
+
+ messaging + +
+
+ + + + +
+
+
brand code (replaces the brand link) @@ -785,6 +921,13 @@ onUnmounted(() => { font-size: 1rem; } +/* The messaging block's language selector sits at the block head's right. */ +.block-head .contact-lang { + margin-left: auto; + flex: 0 0 auto; + width: auto; +} + .text-input { flex: 1; min-width: 4rem; diff --git a/frontend/src/contact-main.js b/frontend/src/contact-main.js index 6f8517f..1e54c97 100644 --- a/frontend/src/contact-main.js +++ b/frontend/src/contact-main.js @@ -25,8 +25,34 @@ const state = reactive({ unread: false, // visitor: an unread admin reply adminUnread: 0, // admin: unanswered threads viewerAdmin: false, // set once the inbox has been opened: icon badge = adminUnread + // Localized site contact strings (pagerite:contact-* asset config, + // refreshed on every open — they change with the page language): + // label doubles as the visitor dialog's title; email gets a mailto + // link with subject prefilled. + label: 'Contact us', + email: '', + subject: '', }) +// The localized contact strings ride the page's asset config (the +// production JSON block, or pagerite:* meta tags in dev). +function readContactStrings() { + let assets = {} + const el = document.getElementById('pagerite-assets') + if (el) { + try { + assets = JSON.parse(el.textContent) + } catch { /* keep empty */ } + } else { + for (const m of document.querySelectorAll('meta[name^="pagerite:contact-"]')) { + assets[m.name] = m.content + } + } + state.label = assets['pagerite:contact-label'] || 'Contact us' + state.subject = assets['pagerite:contact-subject'] || state.label + state.email = assets['pagerite:contact-email'] || '' +} + // The panel is main content, not a sidebar: while open it holds paskia's // shared blur backdrop (refcounted, z-index 1099; the overlay itself sits // at 1100, below the auth iframe's 9999). @@ -154,6 +180,7 @@ export function open(br, { admin = false } = {}) { bridge.register(token, onThread, onPow) } state.mode = admin ? 'admin' : 'visitor' + readContactStrings() state.open = true if (!admin) markSeen() else refreshBadges() diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index 7158ab1..d5ab1ed 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -191,12 +191,16 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; // contact_unread. Once the bundle has loaded, its refreshBadges keeps // the marker live. let adminUnreadSeed = 0; + // Messaging disabled in the site settings (no pagerite:contact-label in + // the asset config): no icon for visitors — admins keep theirs, the + // inbox still holds the existing threads. + const contactEnabled = !!assets["pagerite:contact-label"]; function makeContactIcon(admin) { const b = document.createElement("button"); b.type = "button"; b.id = "contact-icon"; b.className = "contact-icon icon-btn"; - b.title = admin ? "messages" : "contact us"; + b.title = admin ? "messages" : (assets["pagerite:contact-label"] || "contact us"); b.textContent = "✉️"; if (admin) { if (adminUnreadSeed > 0) b.classList.add("has-unread"); @@ -245,8 +249,9 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; const onToolPage = currentPath === "/_a"; if (document.getElementById("page-banner")) { const pens = pensContainer(); - // The contact icon is there for everyone. - pens.append(makeContactIcon(canEdit)); + // The contact icon is there for everyone — except visitors when + // messaging is disabled (admins keep the inbox). + if (canEdit || contactEnabled) pens.append(makeContactIcon(canEdit)); if (canEdit && !onToolPage) { // Analytics viewer is a normal page at /_a. const a = document.createElement("a");