contact messages: settings UI, localized dialog title, mailto link

The site editor gains a messaging block: enabled toggle, contact email,
and label/subject fields with a language selector that switches them to
editing per-language overrides (the manual-override UI for the strings
auto translators fill). The visitor dialog titles itself with the
localized label and shows a mailto link (localized subject prefilled)
when an email is configured; pagerite.js hides the corner icon from
visitors when messaging is disabled.
This commit is contained in:
LeoVasanko committed 2026-09-28 06:33:14 +00:00
1 parent f5fbe8ddc5
commit dd82005f6c
4 files changed
+201 -5

No files matched your search

+23 -2
View File
@@ -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"
>
<header>
<h2>{{ admin ? 'Messages' : 'Contact us' }}</h2>
<h2>{{ admin ? 'Messages' : state.label }}</h2>
<a v-if="!admin && state.email" class="mailto" :href="mailto">{{ state.email }}</a>
<button type="button" class="close" title="close" @click="emit('close')">✕</button>
</header>
<ContactPanel v-if="!admin" :state="state" :token="token" @send="(p) => emit('send', p)" />
@@ -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;
+143
View File
@@ -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(() => {
</div>
</section>
<section class="block">
<div class="block-head">
<span class="field-label">messaging</span>
<select
v-if="translateLangs.length"
v-model="contactLang"
class="text-input theme-select contact-lang"
title="Language of the label/subject strings (auto translators fill these too; this edits the manual override)"
@change="onContactLangChange"
>
<option value="">original</option>
<option v-for="l in translateLangs" :key="l" :value="l">{{ l }}</option>
</select>
</div>
<div class="field-grid">
<label class="field">
<span class="field-label">enabled</span>
<input
v-model="contactEnabled"
type="checkbox"
title="Visitor messaging ({contact:} buttons, corner icon)"
@change="saveSettings()"
/>
</label>
<label class="field">
<span class="field-label">email</span>
<input
v-model="contactEmail"
class="text-input"
title="Contact email — a mailto link in the visitor dialog (empty = no link)"
:disabled="!!translating"
@input="debounce('contact-email', () => saveSettings())"
/>
</label>
<label class="field">
<span class="field-label">label</span>
<input
v-model="contactLabel"
class="text-input"
title="Default {contact:} button label and visitor dialog title (empty = built-in default)"
:placeholder="translating ? contactLabelOrig : 'Contact us'"
@input="onContactInput"
/>
</label>
<label class="field">
<span class="field-label">subject</span>
<input
v-model="contactSubject"
class="text-input"
title="Subject for the mailto link (empty = the label)"
:placeholder="translating ? contactSubjectOrig : ''"
@input="onContactInput"
/>
</label>
</div>
</section>
<section class="block grow" @paste="onBrandPaste">
<div class="block-head">
<span class="field-label">brand code (replaces the brand link)</span>
@@ -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;
+27
View File
@@ -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()
+8 -3
View File
@@ -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");