71ba633ac5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
140 lines
7.3 KiB
JavaScript
140 lines
7.3 KiB
JavaScript
// Live on-chain payout proof: feed section (if #payoutFeed exists) + timed
|
|
// toast pop-ups when payments are seen on the RM Circle contract.
|
|
(function(){
|
|
const SCAN='https://polygonscan.com/tx/';
|
|
const TOAST_FRESH_S=15*60; // toast anything seen on-chain in the last 15 min
|
|
const TOAST_SHOW_MS=8000, TOAST_GAP_MS=2500, TOAST_MAX_QUEUE=5;
|
|
let seen;
|
|
try{seen=new Set(JSON.parse(sessionStorage.getItem('ctb.seenPayouts')||'[]'))}catch(e){seen=new Set()}
|
|
function remember(k){seen.add(k);try{sessionStorage.setItem('ctb.seenPayouts',JSON.stringify([...seen].slice(-300)))}catch(e){}}
|
|
function esc(s){return String(s??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))}
|
|
function fmtPol(n){return n>=100?n.toFixed(1):n.toFixed(2)}
|
|
function timeAgo(ts){
|
|
if(!ts)return '';
|
|
const s=Math.max(0,Math.floor(Date.now()/1000-ts));
|
|
if(s<60)return 'just now';
|
|
if(s<3600)return Math.floor(s/60)+' min ago';
|
|
if(s<86400)return Math.floor(s/3600)+'h ago';
|
|
return Math.floor(s/86400)+'d ago';
|
|
}
|
|
|
|
function verifyHref(p){return p.tx?SCAN+p.tx:'https://polygonscan.com/address/'+(p.toAccount||'0x33BdAEEfd6d17D80aE53816c916dFb26c4fB2DAF')}
|
|
let stack=null;
|
|
function getStack(){
|
|
if(!stack){stack=document.createElement('div');stack.className='pp-stack';document.body.appendChild(stack)}
|
|
return stack;
|
|
}
|
|
const queue=[];let showing=false;
|
|
// On an angle squeeze page (join.js sets data-squeeze) the proof toasts wait
|
|
// until the hook video has ended, so they land as the visitor is deciding
|
|
// rather than competing with the video.
|
|
function held(){return document.documentElement.hasAttribute('data-squeeze')}
|
|
document.addEventListener('rmc:hookended',()=>{if(!showing)showNext()});
|
|
function enqueue(p){if(queue.length>=TOAST_MAX_QUEUE)return;queue.push(p);if(!showing&&!held())showNext()}
|
|
function showNext(){
|
|
if(held()){showing=false;return}
|
|
const p=queue.shift();
|
|
if(!p){showing=false;return}
|
|
showing=true;
|
|
const el=document.createElement('a');
|
|
el.className='pp-toast';el.href=verifyHref(p);el.target='_blank';el.rel='noopener noreferrer';
|
|
const head=p.kind==='upline'&&p.upgrade
|
|
?`🚀 Member #${p.upgrade.id} upgraded to ${esc(p.upgrade.levelName)}`
|
|
:`💸 Member #${p.toId} just got paid`;
|
|
const sub=p.kind==='upline'
|
|
?`${fmtPol(p.pol)} POL paid up to Member #${p.toId}${p.gen?` · Gen ${p.gen} pass-up`:''}`
|
|
:(p.kind==='referral'?`+${fmtPol(p.pol)} POL · direct referral reward`:`+${fmtPol(p.pol)} POL · ${p.desc||p.levelName||'payout'}`);
|
|
el.innerHTML=`<span class="pp-toast-head">${head}</span><span class="pp-toast-sub">${esc(sub)}</span><span class="pp-toast-verify">${timeAgo(p.ts)} · Verify on Polygonscan ↗</span>`;
|
|
getStack().appendChild(el);
|
|
requestAnimationFrame(()=>el.classList.add('show'));
|
|
setTimeout(()=>{el.classList.remove('show');setTimeout(()=>{el.remove();setTimeout(showNext,TOAST_GAP_MS)},450)},TOAST_SHOW_MS);
|
|
}
|
|
|
|
function rowHtml(p){
|
|
const when=timeAgo(p.ts);
|
|
const verify=`<a class="pp-verify" href="${esc(verifyHref(p))}" target="_blank" rel="noopener noreferrer">Verify ↗</a>`;
|
|
if(p.kind==='upline'){
|
|
const up=p.upgrade?`Member #${p.upgrade.id} upgraded to ${esc(p.upgrade.levelName)} — `:'';
|
|
const passed=p.passed&&p.passed.length?`<div class="pp-passed">passed over ${p.passed.map(i=>'#'+i).join(', ')} (not yet eligible — needs 2 directs and the level)</div>`:'';
|
|
return `<div class="pp-row"><div class="pp-icon">🚀</div><div class="pp-body"><strong>${up}${fmtPol(p.pol)} POL paid up to Member #${p.toId}</strong><div class="pp-meta">Upgrade pass-up${p.gen?` · <span class="pp-gen">Gen ${p.gen}</span>`:''} · ${esc(p.levelName)} · ${when}</div>${passed}</div>${verify}</div>`;
|
|
}
|
|
const meta=p.kind==='referral'?`Direct referral reward from #${p.fromId}'s entry`:`${esc(p.desc||p.levelName)} — from #${p.fromId}`;
|
|
return `<div class="pp-row"><div class="pp-icon">💸</div><div class="pp-body"><strong>Member #${p.toId} received ${fmtPol(p.pol)} POL</strong><div class="pp-meta">${meta} · ${when}</div></div>${verify}</div>`;
|
|
}
|
|
// Paginated feed: keep a deduped store of payouts (by key), most-recent first.
|
|
// The live poll refreshes the top (recent) page; "Show more" pulls older pages.
|
|
const PAGE=12;
|
|
const store=new Map(); // key -> payout
|
|
let total=0, visible=PAGE, fetching=false;
|
|
const allSorted=()=>[...store.values()].sort((a,b)=>(b.ts||0)-(a.ts||0));
|
|
|
|
function ingest(d){
|
|
if(d&&Array.isArray(d.payouts))for(const p of d.payouts)store.set(p.key,p);
|
|
if(d&&typeof d.total==='number')total=Math.max(total,d.total);
|
|
}
|
|
function updateTotals(d){
|
|
const totals=document.getElementById('payoutTotals');
|
|
if(totals&&d&&d.totals)totals.innerHTML=`<div class="fact"><small>Members on-chain</small><strong>${d.totals.members}</strong></div><div class="fact"><small>Payouts recorded</small><strong>${d.totals.payouts}</strong></div><div class="fact"><small>POL paid to members</small><strong>${Math.round(d.totals.pol).toLocaleString()}</strong></div>`;
|
|
}
|
|
|
|
function ensureMoreBtn(){
|
|
let btn=document.getElementById('ppMore');
|
|
if(!btn){
|
|
const feed=document.getElementById('payoutFeed');
|
|
if(!feed)return null;
|
|
btn=document.createElement('button');
|
|
btn.id='ppMore';btn.type='button';btn.className='btn btn-secondary pp-more';
|
|
btn.addEventListener('click',onMoreClick);
|
|
feed.parentNode.insertBefore(btn,feed.nextSibling);
|
|
}
|
|
return btn;
|
|
}
|
|
function renderMoreBtn(loadedLen){
|
|
const btn=ensureMoreBtn();if(!btn)return;
|
|
const cap=Math.max(total,loadedLen);
|
|
if(cap<=PAGE){btn.style.display='none';return}
|
|
btn.style.display='';
|
|
const showingNow=Math.min(visible,cap);
|
|
if(showingNow<cap){btn.dataset.mode='more';btn.textContent=`Show more · showing ${showingNow} of ${cap}`;}
|
|
else{btn.dataset.mode='less';btn.textContent='Show fewer';}
|
|
}
|
|
function renderFeed(){
|
|
const feed=document.getElementById('payoutFeed');
|
|
if(!feed)return;
|
|
const all=allSorted();
|
|
if(!all.length){feed.innerHTML='<div class="empty">Reading the blockchain… check back in a minute.</div>';return}
|
|
feed.innerHTML=all.slice(0,visible).map(rowHtml).join('');
|
|
renderMoreBtn(all.length);
|
|
}
|
|
|
|
async function fetchPage(offset,limit){
|
|
if(fetching)return null;
|
|
fetching=true;
|
|
try{const r=await fetch(`/api/public/payouts?offset=${offset}&limit=${limit}`);const d=await r.json();ingest(d);updateTotals(d);return d}
|
|
catch(e){return null}
|
|
finally{fetching=false}
|
|
}
|
|
async function onMoreClick(){
|
|
const btn=document.getElementById('ppMore');
|
|
if(btn&&btn.dataset.mode==='less'){
|
|
visible=PAGE;renderFeed();
|
|
const sec=document.getElementById('proof');if(sec)sec.scrollIntoView({behavior:'smooth',block:'start'});
|
|
return;
|
|
}
|
|
visible+=PAGE;
|
|
if(visible>store.size&&store.size<total){await fetchPage(Math.max(0,store.size-4),PAGE+8);}
|
|
renderFeed();
|
|
}
|
|
|
|
async function poll(){
|
|
const d=await fetchPage(0,40); // recent page drives the live feed + toasts
|
|
if(!d||!d.payouts)return;
|
|
renderFeed();
|
|
const now=Date.now()/1000;
|
|
const fresh=d.payouts.filter(p=>p.ts&&(now-p.ts)<TOAST_FRESH_S&&!seen.has(p.key));
|
|
fresh.slice(0,TOAST_MAX_QUEUE).reverse().forEach(p=>{remember(p.key);enqueue(p)});
|
|
}
|
|
poll();
|
|
setInterval(poll,45000);
|
|
})();
|