Files
pagerite/frontend/src/ContactPanel.vue
T
LeoVasanko afa6b967e6 contact messages: unified overlay, corner ✉️ icon for everyone
The admin inbox leaves the short-lived /_m page and joins the visitor panel as one overlay (ContactOverlay shell over paskia's shared blur backdrop, centered dialog, full-screen on mobile): the corner ✉️ icon opens the visitor thread for visitors and the wider sidebar inbox for admins, while {contact:} trigger buttons always open the visitor side so admins can test both. The icon highlights unread state without extra connections: visitors via localStorage seen/last-admin timestamps, admins via the contact_unread count added to the /_api/settings probe, both kept live by the bundle once loaded. MessagesView owns its admin socket only while the overlay is open.
2026-09-28 01:37:37 +00:00

158 lines
4.2 KiB
Vue

<script setup>
// The visitor's contact thread: messages pushed live over the activity
// socket (via the bridge pagerite.js hands to contact-main), with the
// composer shared with the admin inbox. state is contact-main's reactive
// store ({open, mode, thread, unread, ...}). The overlay shell (backdrop,
// dialog frame, close) is ContactOverlay.vue.
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { formatWhen } from './analytics/format.js'
import Composer from './Composer.vue'
const props = defineProps({
state: { type: Object, required: true },
token: { type: String, required: true },
})
const emit = defineEmits(['send'])
const scroll = ref(null)
async function scrollDown() {
await nextTick()
if (scroll.value) scroll.value.scrollTop = scroll.value.scrollHeight
}
watch(() => props.state.thread?.messages?.length, scrollDown)
watch(() => props.state.open, (open) => { if (open) scrollDown() })
// Relative timestamps like in analytics.
const fmtTime = (t) => formatWhen(t)
const NAME_KEY = 'pagerite-contact-name'
const EMAIL_KEY = 'pagerite-contact-email'
// Name (and optionally email) are asked with the first message; from
// then on the thread's stored identity labels the visitor's messages.
const intro = reactive({
name: localStorage.getItem(NAME_KEY) || '',
email: localStorage.getItem(EMAIL_KEY) || '',
})
const showIntro = computed(() => !props.state.thread?.name)
// Sending stays disabled until the name field is filled.
const introInvalid = computed(() => showIntro.value && !intro.name.trim())
function onSend(md) {
if (showIntro.value) {
const name = intro.name.trim()
const email = intro.email.trim()
localStorage.setItem(NAME_KEY, name)
localStorage.setItem(EMAIL_KEY, email)
emit('send', { md, name, email })
return
}
emit('send', { md })
}
</script>
<template>
<div ref="scroll" class="contact-messages">
<p v-if="!state.thread || !state.thread.messages.length" class="contact-empty">
Send us a message — the reply appears here.
</p>
<div
v-for="m in state.thread?.messages || []"
:key="m.id"
class="contact-msg"
:class="{ mine: !m.admin }"
>
<!-- m.html is the server's strict-markdown rendering of the
message source (pagerite/messages.py, html=False): markdown-it
output only, never user markup. -->
<header>
<span class="who">{{ m.admin || state.thread?.name || 'you' }}</span>
<time>{{ fmtTime(m.t) }}</time>
</header>
<div class="contact-msg-body" v-html="m.html"></div>
</div>
</div>
<div v-if="showIntro" class="contact-intro">
<input v-model="intro.name" type="text" placeholder="Your name" maxlength="100">
<input v-model="intro.email" type="email" placeholder="Your email (optional)" maxlength="200">
</div>
<Composer :token="token" :send-disabled="introInvalid" autofocus @send="onSend" />
</template>
<style scoped>
.contact-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 0.2rem;
}
.contact-empty {
color: var(--muted);
text-align: center;
margin-top: 2rem;
}
.contact-msg {
padding: 0.55rem 0;
}
.contact-msg + .contact-msg {
border-top: 1px solid var(--line);
}
.contact-msg-body {
overflow-wrap: break-word;
}
.contact-msg-body :deep(p) {
margin: 0.25em 0;
}
.contact-msg-body :deep(img) {
max-width: 100%;
border-radius: 0.3rem;
}
.contact-msg-body :deep(a.attachment) {
display: inline-block;
padding: 0.05em 0.5em;
border: 1px solid var(--line);
border-radius: 1em;
background: var(--bg, Canvas);
text-decoration: none;
white-space: nowrap;
}
.contact-msg header {
display: flex;
gap: 0.5rem;
align-items: baseline;
color: var(--muted);
font-size: 0.75rem;
}
.contact-msg.mine header {
justify-content: flex-end;
}
.contact-msg header .who {
font-weight: 600;
color: var(--text, CanvasText);
}
.contact-intro {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.contact-intro input {
flex: 1;
min-width: 8rem;
padding: 0.3rem 0.5rem;
border: 1px solid var(--line);
border-radius: 0.3rem;
background: var(--bg, Canvas);
color: var(--text, CanvasText);
font: inherit;
font-size: 0.9rem;
}
.contact-intro input:focus {
border-color: var(--accent);
outline: none;
}
</style>