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');
+44
View File
@@ -511,3 +511,47 @@ img{max-width:100%}
.feed .row:first-child{animation:landed .9s ease}
@keyframes landed{from{background:rgba(67,232,195,.3)}to{background:var(--mint-soft)}}
}
/* ── Sponsor chat: FAB, slide-in drawer, threads, bubbles, presence ── */
.chat-fab{position:fixed;right:20px;bottom:20px;z-index:60;width:56px;height:56px;border-radius:50%;
border:1px solid var(--line-strong);background:var(--mint);color:var(--mint-ink);display:grid;place-items:center;
cursor:pointer;box-shadow:0 10px 30px rgba(0,0,0,.4);transition:transform .12s ease}
.chat-fab:hover{transform:translateY(-2px)}
.chat-fab-badge{position:absolute;top:-4px;right:-4px;min-width:20px;height:20px;padding:0 5px;border-radius:10px;
background:var(--bad);color:#2a0d08;font:700 12px/20px var(--disp);text-align:center}
.chat-drawer{position:fixed;right:0;top:0;bottom:0;width:380px;max-width:92vw;z-index:70;display:flex;flex-direction:column;
background:var(--panel-solid);border-left:1px solid var(--line-strong);box-shadow:-16px 0 40px rgba(0,0,0,.45);
transform:translateX(0);animation:chatIn .16s ease}
@keyframes chatIn{from{transform:translateX(24px);opacity:.4}to{transform:translateX(0);opacity:1}}
@media (prefers-reduced-motion:reduce){.chat-drawer{animation:none}.chat-fab{transition:none}}
.chat-head{display:flex;align-items:center;gap:8px;padding:12px 12px;border-bottom:1px solid var(--line)}
.chat-who{display:flex;flex-direction:column;line-height:1.15;min-width:0;flex:1}
.chat-who b{font:700 15px var(--disp);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chat-icon{background:none;border:0;color:var(--muted);font-size:26px;line-height:1;cursor:pointer;padding:0 6px}
.chat-icon:hover{color:var(--ink)}
.pres-dot{width:9px;height:9px;border-radius:50%;background:var(--muted);flex:0 0 auto}
.pres-dot.on{background:var(--mint);box-shadow:0 0 8px var(--mint)}
.chat-threads{flex:1;overflow:auto}
.chat-thread{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--line);cursor:pointer}
.chat-thread:hover{background:var(--mint-soft)}
.chat-thread .ct-main{flex:1;min-width:0}
.chat-thread .ct-name{font:700 14px var(--disp)}
.chat-thread .ct-last{color:var(--muted);font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chat-thread .ct-un{min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:var(--mint);color:var(--mint-ink);
font:700 12px/20px var(--disp);text-align:center}
.chat-empty{padding:22px 16px;color:var(--muted);font-size:14px}
.chat-convo{flex:1;display:flex;flex-direction:column;min-height:0}
.chat-msgs{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:8px}
.cbub{max-width:78%;padding:9px 12px;border-radius:14px;font-size:14px;line-height:1.4;white-space:pre-wrap;word-wrap:break-word}
.cbub.them{align-self:flex-start;background:var(--panel);border:1px solid var(--line);border-bottom-left-radius:4px}
.cbub.me{align-self:flex-end;background:var(--mint);color:var(--mint-ink);border-bottom-right-radius:4px}
.cbub .ct-time{display:block;margin-top:4px;font-size:10.5px;opacity:.6}
.chat-day{align-self:center;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.05em;margin:6px 0}
.chat-banner{padding:8px 14px;border-top:1px solid var(--line);color:var(--muted);font-size:12.5px;background:var(--ground2)}
.chat-compose{display:flex;gap:8px;padding:10px;border-top:1px solid var(--line);align-items:flex-end}
.chat-compose textarea{flex:1;resize:none;max-height:120px;background:var(--ground2);border:1px solid var(--line);
border-radius:12px;color:var(--ink);padding:9px 11px;font:400 14px var(--disp)}
.chat-compose .btn{padding:9px 16px}
.chat-msg-btn{margin-left:auto;font-size:12px;padding:3px 10px}
.switch{display:flex;align-items:center;gap:10px;cursor:pointer;font-size:14px}
.switch input{width:18px;height:18px;accent-color:var(--mint)}