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');
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user