Live members-area updates: SSE toasts + cha-ching/pop sounds, no refresh needed

- The members area subscribes to the chain event stream (/api/feed/live) and
  reacts to anything relevant to the member: a payout received (cha-ching + toast
  + auto-refresh), a referral qualifying, a passed-up payout (missed), a settled
  purchase, a new activation in the line. No page refresh required.
- New chat message -> "pop" sound + toast + badge; ping heartbeat (20s) now
  returns chatUnread and pops on an increase.
- Sounds are synthesized via Web Audio (no asset files, CSP-safe).
- Surge badge: nudge the name down (ribbonY .779 -> .805) to center on its ribbon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-07 07:36:47 -05:00
parent 7fbf9add37
commit 958bd5ac20
3 changed files with 70 additions and 9 deletions
+63 -3
View File
@@ -44,7 +44,7 @@
// achievement badges: the milestone ladder as unlockable medals + share-to-image
const BADGES = [ // ribbonY = vertical center of each badge's name ribbon (art differs per tier)
{ key: 'payouts', label: 'Spark', sub: 'payouts on', img: '/badges/badge-spark.jpg', ribbonY: 0.728 },
{ key: 'firstBuyer', label: 'Surge', sub: 'first qualifying buyer', img: '/badges/badge-surge.jpg', ribbonY: 0.779 },
{ key: 'firstBuyer', label: 'Surge', sub: 'first qualifying buyer', img: '/badges/badge-surge.jpg', ribbonY: 0.805 },
{ key: 'level2', label: 'Circuit', sub: '2 qualifying buyers', img: '/badges/badge-circuit.jpg', ribbonY: 0.713 },
{ key: 'level3', label: 'Nexus', sub: 'fully qualified', img: '/badges/badge-nexus.jpg', ribbonY: 0.709 }
];
@@ -209,11 +209,62 @@
if (!cs.length) $('chCampSub').textContent = 'no campaigns yet — place your first ad';
} catch (e) {}
}
// ── live updates: subscribe to the chain event stream so the members area
// reacts to on-chain changes (payouts, qualifications) without a refresh ──
let MYID = 0;
// synthesized sounds (no asset files; CSP-safe). cha-ching on a payment, pop on a message.
function playSound(kind) {
try {
const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return;
const ctx = window.__iapAC || (window.__iapAC = new AC());
if (ctx.state === 'suspended') ctx.resume();
const now = ctx.currentTime;
const tone = (freq, at, dur, type, peak) => {
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = type || 'sine'; o.frequency.value = freq;
g.gain.setValueAtTime(0.0001, now + at);
g.gain.exponentialRampToValueAtTime(peak || 0.22, now + at + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, now + at + dur);
o.connect(g).connect(ctx.destination); o.start(now + at); o.stop(now + at + dur + 0.02);
};
if (kind === 'chaching') { tone(1318, 0, 0.34, 'sine', 0.25); tone(1760, 0.09, 0.4, 'sine', 0.25); }
else { tone(680, 0, 0.16, 'triangle', 0.18); tone(1020, 0.05, 0.16, 'triangle', 0.16); }
} catch (e) {}
}
function startLiveFeed() {
if (window.__iapFeed || !window.EventSource) return;
try {
const es = new EventSource('/api/feed/live');
window.__iapFeed = es;
es.onmessage = m => { let ev; try { ev = JSON.parse(m.data); } catch (e) { return; } handleLiveEvent(ev); };
// browser auto-reconnects on error; nothing to do
} catch (e) {}
}
let liveRefreshT = null;
function liveRefresh() { // debounce a burst of events into one refresh
clearTimeout(liveRefreshT);
liveRefreshT = setTimeout(() => { loadDashboard(); try { loadLineage(); } catch (e) {} }, 600);
}
function handleLiveEvent(ev) {
if (!MYID || !ev || !ev.type) return;
let toast = null, kind = 'ok';
let sound = null;
if (ev.type === 'TierPaid' && ev.recipientId === MYID) { toast = '💸 You earned a level-' + ev.tier + ' payout of ' + IAP.fmtPol(ev.amountWei) + ' POL!'; sound = 'chaching'; }
else if (ev.type === 'AwardPaid' && ev.toId === MYID) { toast = '💸 You received ' + IAP.fmtPol(ev.amountWei) + ' POL!'; sound = 'chaching'; }
else if (ev.type === 'BuyerCounted' && ev.sponsorId === MYID) toast = '🎯 A referral just qualified — you now have ' + ev.newCount + ' qualifying buyer' + (ev.newCount === 1 ? '' : 's') + '!';
else if (ev.type === 'PassedUp' && ev.skippedId === MYID) { toast = '⚠️ A level-' + ev.tier + ' payout passed you by. Get qualified to catch these.'; kind = 'bad'; }
else if (ev.type === 'Purchase' && ev.buyerId === MYID) toast = '✅ Purchase settled on-chain — your credits are updated.';
else if (ev.type === 'MemberActivated' && ev.sponsorId === MYID) toast = '🤝 A new member just activated in your line!';
if (toast) { IAP.status(toast, kind); if (sound) playSound(sound); liveRefresh(); }
}
async function loadDashboard() {
try {
const d = await (await fetch('/api/my/dashboard')).json();
if (d.error) return;
chatSync(d);
MYID = d.memberId || MYID;
startLiveFeed();
const wc = d.welcomeCredits || 0;
$('dbCredits').textContent = ((d.credits || 0) + wc).toLocaleString();
$('dbCreditsSub').textContent = wc ? (d.credits || 0).toLocaleString() + ' purchased · ' + wc + ' welcome' : '';
@@ -1398,12 +1449,13 @@
}
// ── 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, CHAT_SPONSOR = null;
let CHAT_ME = null, CHAT_OTHER = null, CHAT_LASTID = 0, CHAT_POLL = null, CHAT_CANMUTE = false, CHAT_IMUTE = false, CHAT_SPONSOR = null, CHAT_LAST_UNREAD = 0;
function chatSync(d) {
CHAT_ME = d.email || CHAT_ME;
CHAT_SPONSOR = (d.sponsor && d.sponsor.email) ? d.sponsor : null;
const link = $('chatMenuBtn'); if (link && link.closest('.bo-links')) link.closest('.bo-links').hidden = !d.email;
setChatBadge(d.chatUnread || 0);
CHAT_LAST_UNREAD = d.chatUnread || 0;
const tog = $('chatAvailToggle'); if (tog) tog.checked = d.chatAvailable !== false;
// Overview quick action + My line card: message-your-sponsor entry points
const qs = $('qaMsgSponsor');
@@ -1539,7 +1591,15 @@
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);
// presence heartbeat + new-message notifier: pop + toast when unread rises
setInterval(async () => {
try {
const r = await (await fetch('/api/my/ping')).json();
const n = r.chatUnread || 0;
if (n > CHAT_LAST_UNREAD) { playSound('pop'); IAP.status('💬 New message from your team.', 'ok'); }
CHAT_LAST_UNREAD = n; setChatBadge(n);
} catch (e) {}
}, 20000);
})();
// ad surfaces: a banner greets the sign-in screen; members see live