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:
+63
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user