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.
158 lines
4.2 KiB
Vue
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>
|