From 958bd5ac20a458b05e3d62e4725fa43f4cd69961 Mon Sep 17 00:00:00 2001 From: martbost Date: Mon, 7 Sep 2026 07:36:47 -0500 Subject: [PATCH] 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 --- public/assets/my.js | 66 ++++++++++++++++++++++++++++++++++++++++++--- public/my.html | 8 +++--- server.js | 5 ++-- 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/public/assets/my.js b/public/assets/my.js index 2b1929f..b9ab15f 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -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 diff --git a/public/my.html b/public/my.html index f52fe40..5ed60b4 100644 --- a/public/my.html +++ b/public/my.html @@ -4,7 +4,7 @@ Member area | InstantAdPay - + @@ -641,9 +641,9 @@ - + - - + + diff --git a/server.js b/server.js index e989928..9c043ca 100644 --- a/server.js +++ b/server.js @@ -680,8 +680,9 @@ const server = http.createServer(async (req, res) => { // lightweight presence heartbeat (called on a timer while the dashboard is open) if (p === '/api/my/ping' && req.method === 'GET') { const s = await auth.fromRequest(req); - if (s && s.email) accounts.touchSeen(s.email).catch(() => {}); - return json(res, 200, { ok: true }); + if (!s || !s.email) return json(res, 200, { ok: true, chatUnread: 0 }); + accounts.touchSeen(s.email).catch(() => {}); + return json(res, 200, { ok: true, chatUnread: await messages.chatUnread(s.email) }); } // -- rehearsal test-POL faucet: top a connected wallet up to 10 test-POL // (anvil_setBalance, no key needed). Rehearsal-only, rate-limited.