Paginate the live payment proof feed (Show more / Show fewer)

The feed showed only the latest 12 of 136 recorded payouts. Now:
- chain.getPayoutsPublic(offset,limit) returns a page of the full
  reversed history plus total/offset/limit/hasMore (limit capped 100).
- /api/public/payouts accepts ?offset & ?limit (defaults 0/40, so the
  live poll + toast detection are unchanged).
- payouts.js keeps a deduped store keyed by payout key; the live poll
  refreshes the recent page while "Show more" pulls older pages 12 at a
  time (with slight overlap so a newly-arrived payout can't open a gap),
  and "Show fewer" collapses back. Verified in-browser: 12 → 48 → 136
  rows, button flips to "Show fewer", collapses to 12, 0 console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-15 11:16:12 -05:00
parent 95bc5bc90f
commit d36a405581
4 changed files with 88 additions and 22 deletions
+63 -8
View File
@@ -55,20 +55,75 @@
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>`;
}
function render(d){
// 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 totals=document.getElementById('payoutTotals');
if(totals&&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>`;
if(!d.payouts||!d.payouts.length){feed.innerHTML='<div class="empty">Reading the blockchain… check back in a minute.</div>';return}
feed.innerHTML=d.payouts.slice(0,12).map(rowHtml).join('');
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(){
let d;
try{const r=await fetch('/api/public/payouts');d=await r.json()}catch(e){return}
const d=await fetchPage(0,40); // recent page drives the live feed + toasts
if(!d||!d.payouts)return;
render(d);
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)});