← back to Rentv 2026
public/admin/pr-intelligence/inbox.html
156 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>CRE PR Intelligence — Inbox</title>
<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
</head>
<body>
<script src="/admin/pr-intelligence/pr-shared.js"></script>
<script>
(async function () {
const c = PR.frame('inbox', 'Inbox & Replies', 'Shared response center. Replies sync from the email provider; sends always require an explicit click; opt-outs suppress immediately.');
const meta = await PR.api('/meta');
const prov = meta.email_provider;
c.innerHTML = `
<div class="panel" style="display:flex;gap:12px;align-items:center">
<b>Email provider:</b> ${PR.badge(prov.active, prov.active === 'gmail' ? 'green' : 'amber')}
<span style="color:var(--muted);font-size:12.5px">${PR.esc(prov.note)}</span>
<button class="btn sm" id="sync" style="margin-left:auto">↻ Sync threads</button>
</div>
<div class="cols2">
<div>
<div class="panel"><h3>Replies</h3><div id="replies"><span class="spin"></span></div></div>
<div class="panel"><h3>Follow-ups due</h3><div id="followups"><span class="spin"></span></div></div>
<div class="panel"><h3>Sent / pipeline</h3><div id="pipeline"><span class="spin"></span></div></div>
</div>
<div id="thread-pane"><div class="panel"><div class="empty">Select a thread</div></div></div>
</div>`;
document.getElementById('sync').addEventListener('click', async () => {
const r = await PR.guard(() => PR.api('/inbox/sync', { method: 'POST' }), 'Synced');
PR.toast(`Checked ${r.threads_checked} thread(s), ${r.new_replies} new repl(ies)` + (r.errors.length ? ', ' + r.errors.length + ' error(s)' : ''));
load();
});
async function load() {
const j = await PR.api('/inbox');
document.getElementById('replies').innerHTML = j.replies.length ? j.replies.map((m) => `
<div class="gatechk" style="cursor:pointer" data-thread="${PR.esc(m.provider_thread_id || '')}" data-mid="${m.id}">
<b>${PR.esc(m.full_name || '(unknown)')}</b><span style="color:var(--muted)">${PR.esc(m.organization_name || '')}</span>
<span style="flex:1;color:var(--muted);font-size:12px">${PR.esc((m.snippet || '').slice(0, 80))}</span>
<span style="color:var(--muted);font-size:11px">${PR.dateShort(m.replied_at)}</span></div>`).join('') : '<div class="empty">No replies yet</div>';
document.getElementById('followups').innerHTML = j.followups.length ? j.followups.map((m) => `
<div class="gatechk" style="cursor:pointer" data-thread="${PR.esc(m.provider_thread_id || '')}">
<b>${PR.esc(m.full_name || '')}</b><span>${PR.esc(m.subject || '')}</span>
<span style="margin-left:auto;color:var(--warn);font-size:12px">due ${PR.dateShort(m.follow_up_at)}</span></div>`).join('') : '<div class="empty">Nothing due</div>';
const pipe = await PR.api('/outreach?direction=outbound&limit=25');
document.getElementById('pipeline').innerHTML = pipe.rows.length ? pipe.rows.map((m) => `
<div class="gatechk" style="cursor:pointer" data-thread="${PR.esc(m.provider_thread_id || '')}" data-mid="${m.id}">
${PR.statusBadge(m.status)} <b>${PR.esc(m.full_name || '')}</b>
<span style="color:var(--muted);font-size:12px">${PR.esc(m.subject || '')}</span>
<span style="margin-left:auto;color:var(--muted);font-size:11px">${PR.dateShort(m.sent_at || m.drafted_at)}</span></div>`).join('') : '<div class="empty">No outreach yet</div>';
document.querySelectorAll('[data-thread]').forEach((el) => el.addEventListener('click', () => openThread(el.dataset.thread, el.dataset.mid)));
}
async function openThread(threadId, mid) {
let msgs = [];
if (threadId) msgs = (await PR.api('/inbox/thread/' + encodeURIComponent(threadId))).messages;
else if (mid) { const m = await PR.api('/outreach/' + mid); msgs = [m]; }
if (!msgs.length) return;
const root = msgs[0];
const pane = document.getElementById('thread-pane');
pane.innerHTML = `
<div class="panel">
<h3>${PR.esc(root.subject || '(no subject)')}</h3>
<div style="font-size:12.5px;color:var(--muted);margin:0 0 10px">
${PR.esc(root.full_name || '')} · ${PR.esc(root.organization_name || '')}
${root.person_id ? `· <a href="/admin/pr-intelligence/people?id=${root.person_id}">person record →</a>` : ''}</div>
<div class="thread">${msgs.map((m) => `
<div class="msg ${m.direction === 'inbound' ? 'in' : 'out'}">
<div class="mh"><b>${m.direction === 'inbound' ? '← Inbound' : '→ Outbound'}</b> ${PR.statusBadge(m.status)}
<span>${PR.date(m.sent_at || m.replied_at || m.created_at)}</span></div>
<pre>${PR.esc(m.rendered_text || m.body_preserved || '(html only)')}</pre>
${m.status === 'provider_draft_created' && m.direction === 'outbound' ? `
<div class="filters" style="margin-top:8px">
<button class="btn sm primary" data-send="${m.id}">📤 Send now (explicit)</button>
<span style="font-size:11px;color:var(--muted)">re-checks suppression at send time</span></div>` : ''}
</div>`).join('')}</div>
<h3 style="margin:14px 0 8px">Reply</h3>
<div class="filters">
${[["Thank + confirm added to press list", "Thank you — you're on RENTV's list for {{org}} announcements. We've noted your preferred submission route."],
["Ask for correct PR contact", "Thanks for the note — could you point me to the colleague who handles the media/press list so I reach the right person?"],
["Confirm submission method", "Happy to use whichever channel works best — should releases go to this address, or is there a press inbox you prefer?"],
["Ask about CA/AZ coverage", "We focus on California and Arizona commercial real estate — does your team issue announcements for those markets?"]]
.map(([label, text], i) => `<button class="btn sm" data-canned="${i}" data-text="${PR.esc(text)}">${label}</button>`).join('')}
</div>
<textarea id="reply-text" style="width:100%;min-height:110px;border:1px solid var(--line);border-radius:8px;padding:10px;font:13px inherit" placeholder="Write the reply…"></textarea>
<div class="filters" style="margin-top:8px">
<button class="btn primary" id="reply-draft">Create provider draft</button>
<span style="font-size:11.5px;color:var(--muted)">Creates a draft in ${prov.active}; sending is a separate explicit click.</span>
</div>
<h3 style="margin:16px 0 8px">Actions</h3>
<div class="filters">
<button class="btn sm" data-act="interested">Mark interested</button>
<button class="btn sm" data-act="not_now">Not now</button>
<button class="btn sm" id="act-referral">Add referred contact</button>
<button class="btn sm" id="act-task">Add task</button>
<button class="btn sm" id="act-wrong">Wrong person</button>
<button class="btn sm danger" id="act-optout">Opt out / suppress</button>
<button class="btn sm" data-act="closed">Close thread</button>
</div>
</div>`;
const lastOut = [...msgs].reverse().find((m) => m.direction === 'outbound');
pane.querySelectorAll('[data-canned]').forEach((b) => b.addEventListener('click', () => {
document.getElementById('reply-text').value = b.dataset.text.replace('{{org}}', root.organization_name || 'your organization');
}));
pane.querySelector('#reply-draft').addEventListener('click', async () => {
const text = document.getElementById('reply-text').value.trim();
if (!text) return PR.toast('Write the reply first', true);
if (!threadId) return PR.toast('This message has no provider thread yet (create + send the provider draft first)', true);
await PR.guard(() => PR.api('/inbox/reply', { method: 'POST', body: { thread_id: threadId, body_text: text } }), 'Reply draft created in ' + prov.active);
openThread(threadId);
});
pane.querySelectorAll('[data-send]').forEach((b) => b.addEventListener('click', async () => {
if (!confirm('Send this message now? This is the explicit send step.')) return;
await PR.guard(() => PR.api('/outreach/' + b.dataset.send + '/send', { method: 'POST', body: { confirm: true } }), 'Sent');
openThread(threadId, mid);
}));
pane.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', async () => {
if (!lastOut) return PR.toast('No outbound message on this thread', true);
await PR.guard(() => PR.api('/outreach/' + lastOut.id + '/status', { method: 'POST', body: { status: b.dataset.act } }), 'Marked ' + b.dataset.act);
openThread(threadId, mid); load();
}));
pane.querySelector('#act-referral').addEventListener('click', async () => {
if (!lastOut) return;
const name = prompt('Referred person\'s full name:'); if (!name) return;
const title = prompt('Their title (optional):') || null;
const email = prompt('Their email, if given in the reply (optional):') || null;
const r = await PR.guard(() => PR.api('/inbox/referral', { method: 'POST', body: { message_id: lastOut.id, referred_name: name, referred_title: title, referred_email: email } }), 'Referral recorded + follow-up task created');
location.href = '/admin/pr-intelligence/people?id=' + r.referred_person.id;
});
pane.querySelector('#act-task').addEventListener('click', async () => {
const title = prompt('Task:'); if (!title) return;
await PR.guard(() => PR.api('/tasks', { method: 'POST', body: { title, entity_type: 'message', entity_id: (lastOut || root).id } }), 'Task added');
});
pane.querySelector('#act-wrong').addEventListener('click', async () => {
if (!root.person_id) return;
await PR.guard(() => PR.api('/people/' + root.person_id, { method: 'PUT', body: { lifecycle_status: 'wrong_person' } }), 'Marked wrong person');
});
pane.querySelector('#act-optout').addEventListener('click', async () => {
if (!confirm('Add this contact to the suppression list (permanent opt-out)?')) return;
await PR.guard(() => PR.api('/suppression', { method: 'POST', body: { person_id: root.person_id, email: root.public_work_email, reason: 'opt_out', source: 'reply' } }), 'Suppressed');
load();
});
}
load();
const deepThread = new URLSearchParams(location.search).get('thread');
if (deepThread) openThread(deepThread);
})();
</script>
</body>
</html>