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:
+135
-1
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user