Sponsor Chat: two-way member<->sponsor messaging (presence, availability, mute)

Evolves the one-way broadcast into real support threads, keeping broadcast alongside.
- messages.js: kind='chat' rides sponsor_messages; sendChat/thread/threadList/
  markChatRead/chatUnread; broadcast inbox + login modal scoped to kind='broadcast'
- accounts.js: presence (last_seen), chat availability toggle, per-member mutes;
  sponsorOf() + isDownlineOf() resolvers
- server.js: /api/my/chat/{send,thread,threads,available,mute} + /api/my/ping
  heartbeat; dashboard emits chatUnread/chatAvailable/sponsor; auth = direct
  sponsor up, any downline down, or an existing thread; email only when offline
  and not mid-chat
- my.html/my.js/site.css: slide-in chat drawer (threads list + live thread,
  4s poll), presence dot, Message buttons on direct rows, Message-my-sponsor
  quick action, availability switch in Profile
- db.js: additive migrations (kind, pair index, chat_available, chat_mutes)
- chatbot.js: canned answer + AI fact for Sponsor Chat

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-07 05:24:23 -05:00
parent 23136f4ba0
commit ade0a13064
8 changed files with 475 additions and 18 deletions
+135 -1
View File
@@ -205,6 +205,7 @@
try {
const d = await (await fetch('/api/my/dashboard')).json();
if (d.error) return;
chatSync(d);
const wc = d.welcomeCredits || 0;
$('dbCredits').textContent = ((d.credits || 0) + wc).toLocaleString();
$('dbCreditsSub').textContent = wc ? (d.credits || 0).toLocaleString() + ' purchased · ' + wc + ' welcome' : '';
@@ -761,8 +762,12 @@
'<div class="lin-lvl"><div class="cap">Level ' + L.level + ' · ' + L.members.length + (L.level === 1 ? ' direct' : '') + '</div>'
+ L.members.map(m => '<div class="lin-row"><span class="nm">' + esc(m.name) + '</span>'
+ (m.email ? '<span class="em">' + esc(m.email) + '</span>' : '<span class="id">#' + m.memberId + '</span>')
+ '<span class="id" style="margin-left:auto">' + new Date(m.joined).toLocaleDateString() + '</span></div>').join('')
+ '<span class="id" style="margin-left:auto">' + new Date(m.joined).toLocaleDateString() + '</span>'
+ (m.email ? '<button class="btn sec small chat-msg-btn" type="button" data-cemail="' + esc(m.email) + '" data-cname="' + esc(m.name) + '">Message</button>' : '')
+ '</div>').join('')
+ '</div>').join('');
el.querySelectorAll('[data-cemail]').forEach(b =>
b.addEventListener('click', () => openConvo(b.dataset.cemail, b.dataset.cname)));
} catch (e) {}
}
async function loadUplineMessages() {
@@ -1330,6 +1335,135 @@
} catch (e) {}
}
// ── SPONSOR CHAT: two-way threads, presence-aware, with a slide-in drawer ──
let CHAT_ME = null, CHAT_OTHER = null, CHAT_LASTID = 0, CHAT_POLL = null, CHAT_CANMUTE = false, CHAT_IMUTE = false;
function chatSync(d) {
CHAT_ME = d.email || CHAT_ME;
const fab = $('chatFab'); if (fab) fab.hidden = !d.email;
setChatBadge(d.chatUnread || 0);
const tog = $('chatAvailToggle'); if (tog) tog.checked = d.chatAvailable !== false;
const qs = $('qaMsgSponsor');
if (qs) {
if (d.sponsor && d.sponsor.email) { qs.hidden = false; qs.dataset.email = d.sponsor.email; qs.dataset.name = d.sponsor.name || 'your sponsor'; }
else qs.hidden = true;
}
}
function setChatBadge(n) { const b = $('chatFabBadge'); if (b) { b.hidden = !n; b.textContent = n > 9 ? '9+' : n; } }
function stopPoll() { if (CHAT_POLL) { clearInterval(CHAT_POLL); CHAT_POLL = null; } }
function startPoll() { stopPoll(); CHAT_POLL = setInterval(() => pullThread(false), 4000); }
function autoGrow(el) { el.style.height = 'auto'; el.style.height = Math.min(120, el.scrollHeight) + 'px'; }
function closeDrawer() { stopPoll(); if ($('chatDrawer')) $('chatDrawer').hidden = true; CHAT_OTHER = null; loadDashboard(); }
async function openThreads() {
const dr = $('chatDrawer'); if (!dr) return;
dr.hidden = false; $('chatConvo').hidden = true; $('chatThreads').hidden = false;
$('chatBack').hidden = true; $('chatMute').hidden = true; $('chatDot').hidden = true;
$('chatTitle').textContent = 'Messages'; $('chatStatus').textContent = '';
stopPoll(); CHAT_OTHER = null;
$('chatThreads').innerHTML = '<p class="muted small" style="padding:14px">Loading…</p>';
try {
const r = await (await fetch('/api/my/chat/threads')).json();
const list = r.threads || [];
if (!list.length) { $('chatThreads').innerHTML = '<div class="chat-empty">No conversations yet. Message your sponsor from Overview, or anyone in your line from “My line”.</div>'; return; }
$('chatThreads').innerHTML = list.map(t =>
'<div class="chat-thread" data-email="' + esc(t.email) + '" data-name="' + esc(t.name) + '">'
+ '<span class="pres-dot' + (t.online ? ' on' : '') + '"></span>'
+ '<div class="ct-main"><div class="ct-name">' + esc(t.name) + '</div>'
+ '<div class="ct-last">' + (t.last.fromMe ? 'You: ' : '') + esc((t.last.body || '').slice(0, 64)) + '</div></div>'
+ (t.unread ? '<span class="ct-un">' + t.unread + '</span>' : '') + '</div>').join('');
$('chatThreads').querySelectorAll('.chat-thread').forEach(el =>
el.addEventListener('click', () => openConvo(el.dataset.email, el.dataset.name)));
} catch (e) { $('chatThreads').innerHTML = '<div class="chat-empty">Could not load messages.</div>'; }
}
async function openConvo(email, name) {
if (!email) return;
CHAT_OTHER = email; CHAT_LASTID = 0;
const dr = $('chatDrawer'); if (!dr) return;
dr.hidden = false; $('chatThreads').hidden = true; $('chatConvo').hidden = false;
$('chatBack').hidden = false; $('chatDot').hidden = false;
$('chatTitle').textContent = name || email; $('chatStatus').textContent = '…';
$('chatMsgs').innerHTML = ''; $('chatBanner').hidden = true;
$('chatInput').disabled = false; $('chatSend').disabled = false;
await pullThread(true); startPoll();
setTimeout(() => { const i = $('chatInput'); if (i) i.focus(); }, 60);
}
function renderMsgs(msgs) {
const box = $('chatMsgs'); if (!box) return;
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 60;
for (const m of msgs) {
if (m.id <= CHAT_LASTID) continue;
CHAT_LASTID = Math.max(CHAT_LASTID, m.id);
const d = document.createElement('div');
d.className = 'cbub ' + (m.fromMe ? 'me' : 'them');
d.textContent = m.body;
const t = document.createElement('span'); t.className = 'ct-time';
t.textContent = new Date(m.sent).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
d.appendChild(t); box.appendChild(d);
}
if (atBottom) box.scrollTop = box.scrollHeight;
}
async function pullThread(reset) {
if (!CHAT_OTHER) return;
try {
const r = await (await fetch('/api/my/chat/thread?with=' + encodeURIComponent(CHAT_OTHER) + '&after=' + (reset ? 0 : CHAT_LASTID))).json();
if (r.error) { $('chatBanner').hidden = false; $('chatBanner').textContent = r.error; return; }
if (reset) { $('chatMsgs').innerHTML = ''; CHAT_LASTID = 0; }
renderMsgs(r.messages || []);
$('chatDot').className = 'pres-dot' + (r.online ? ' on' : '');
$('chatStatus').textContent = r.online ? 'active now' : (r.available ? 'away · will get your note' : 'not taking live chats · leave a note');
CHAT_CANMUTE = !!r.canMute; CHAT_IMUTE = !!r.iMute;
const mb = $('chatMute'); mb.hidden = !CHAT_CANMUTE; mb.textContent = CHAT_IMUTE ? 'Unmute' : 'Mute';
const blocked = !!r.blocked;
$('chatInput').disabled = blocked; $('chatSend').disabled = blocked;
if (blocked) { $('chatBanner').hidden = false; $('chatBanner').textContent = 'They are not accepting messages from you right now.'; }
else if (CHAT_IMUTE) { $('chatBanner').hidden = false; $('chatBanner').textContent = 'You muted this member. Unmute to let them message you again.'; }
else $('chatBanner').hidden = true;
} catch (e) {}
}
async function chatSend() {
const inp = $('chatInput'); if (!inp) return;
const text = inp.value.trim(); if (!text || !CHAT_OTHER) return;
$('chatSend').disabled = true;
try {
const r = await api('/api/my/chat/send', { to: CHAT_OTHER, body: text });
inp.value = ''; autoGrow(inp); renderMsgs([r.message]);
const box = $('chatMsgs'); box.scrollTop = box.scrollHeight;
} catch (e) { IAP.status(e.message || 'Could not send.', 'bad'); }
finally { $('chatSend').disabled = false; inp.focus(); }
}
async function toggleMute() {
if (!CHAT_OTHER) return;
try { const r = await api('/api/my/chat/mute', { email: CHAT_OTHER, muted: !CHAT_IMUTE }); CHAT_IMUTE = r.muted; $('chatMute').textContent = CHAT_IMUTE ? 'Unmute' : 'Mute'; pullThread(false); }
catch (e) { IAP.status(e.message, 'bad'); }
}
(function chatInit() {
if (!$('chatDrawer')) return;
const on = (id, ev, fn) => { const el = $(id); if (el) el.addEventListener(ev, fn); };
on('chatFab', 'click', openThreads);
on('chatClose', 'click', closeDrawer);
on('chatBack', 'click', openThreads);
on('chatMute', 'click', toggleMute);
on('chatSend', 'click', chatSend);
const inp = $('chatInput');
if (inp) {
inp.addEventListener('input', () => autoGrow(inp));
inp.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chatSend(); } });
}
on('qaMsgSponsor', 'click', () => { const q = $('qaMsgSponsor'); if (q.dataset.email) openConvo(q.dataset.email, q.dataset.name || 'your sponsor'); });
on('chatAvailToggle', 'change', async () => {
const t = $('chatAvailToggle');
try { await api('/api/my/chat/available', { available: t.checked }); IAP.status(t.checked ? 'You are available to chat with your line.' : 'Live chat off. People can still leave you a note.', 'ok'); }
catch (e) { IAP.status(e.message, 'bad'); t.checked = !t.checked; }
});
setInterval(() => { fetch('/api/my/ping').catch(() => {}); }, 45000);
})();
// ad surfaces: a banner greets the sign-in screen; members see live
// banner + text placements inside the back office (they ARE the audience)
IAP.adSlot('banner', 'adSlotLogin');