Member profiles are optional: an invitation on the dashboard, never a gate

Manson's objection was that requiring a username and a verified email pulls the
build back toward a centralized database of members. He is right, and the
communication gap is real too, so the answer is to ask well rather than to force.

Nothing about holding a position, getting paid, reading the org, the training or
the tools depends on contact details any more. There is no onboarding gate: a
brand-new member registers, lands on their page and is never stopped by a modal.
The dashboard offers a dismissable card ("Not now" snoozes it for a week) that
leads with the thing members actually want, a note the moment a payout lands in
their wallet, and says outright that everything works the same without it. The
inbox is the one place that asks, because a message cannot be delivered to
someone who left no way to reach them, and even there it is an invitation.

The card sits above the tab strip rather than inside the dashboard pane: the page
opens on the pitch tab, so an invitation parked in the dashboard would never be
seen by the new members it is aimed at.

For leaders, /api/public/reach answers "how many of my org can I reach off the
site", scoped by chain.isInTeam so it leaks nothing upward or sideways. That
makes coverage a leader's own problem to solve by asking, not a rule imposed on
members.

Fixes a real bug found by the rewritten suite: the dismissable flag double-booked
as "single-field edit", so saving a username in the opt-in flow closed the dialog
instead of advancing to the email step. Split into oneShot; the suite now asserts
the advance as a regression.

QA, all green: profiles-unit 28, signin-fallback 7, gate-e2e 33 (rewritten to
assert the opposite of what it used to: no forced modal, dismissable everywhere,
visitors unaffected), join-flow 12 cold / 11 refuse / 12 warm.

qa/reseed.sh carries two hard-won guards: never name a shell variable TMP on
Windows (it inherits the system temp dir and rm -rf wipes it), and never pkill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-17 04:44:09 -05:00
parent 0189278aa5
commit 91a6893df9
9 changed files with 386 additions and 225 deletions
+63 -4
View File
@@ -315,6 +315,45 @@
}).catch(()=>{});
loadMsgUI(d);
}
// A quiet, dismissable invitation on the member's own dashboard. Snoozed for a
// week when dismissed; never shown on a teammate's page or to a visitor.
async function maybeProfileCard(id){
try{
var snoozed=0; try{ snoozed=Number(localStorage.getItem('rmc.profileSnooze')||0); }catch(e){}
if(Date.now()<snoozed) return;
var pr=await window.RMCProfile.status();
if(!pr||pr.signedIn===false||Number(pr.id)!==Number(id)) return;
if(pr.profile&&pr.profile.complete) return;
// Above the tab strip, NOT inside the dashboard pane. The page opens on the
// pitch tab, so a member who just registered would never see an invitation
// parked in the dashboard, and an invitation nobody sees collects nothing.
var tabs=document.querySelector('#dash .mp-tabs'); if(!tabs) return;
var box=document.createElement('div');
box.className='table-card';
box.style.cssText='margin:0 0 18px;border-color:rgba(78,214,203,.4)';
box.innerHTML='<h2 style="margin:0 0 4px">Get a note when you get paid</h2>'+
'<p style="color:var(--muted);font-size:13px;margin:0 0 12px">Add an email and we will tell you the moment POL lands in your wallet, and your sponsor can reach you when something needs you. '+
'Completely optional, never shown to other members, never sold, and removable any time. Your position, your payouts and everything on this page work exactly the same without it.</p>'+
'<button id="pcGo" class="btn btn-teal btn-sm">Set this up</button> '+
'<button id="pcNo" class="btn btn-secondary btn-sm" style="margin-left:6px">Not now</button>';
tabs.parentNode.insertBefore(box,tabs);
document.getElementById('pcGo').addEventListener('click',async function(){
await window.RMCProfile.prompt({onlyForId:id,reason:'alerts'});
var p2=await window.RMCProfile.status();
if(p2&&p2.profile&&p2.profile.complete){ box.remove(); try{ renderAlerts({id:id}); }catch(e){} }
});
document.getElementById('pcNo').addEventListener('click',function(){
try{ localStorage.setItem('rmc.profileSnooze',String(Date.now()+7*86400000)); }catch(e){}
box.remove();
});
}catch(e){}
}
// every position below this one, from the matrix subtree the dashboard already has
function orgPositionIds(d){
const out=[];
(function walk(n,depth){ if(!n||depth>16)return; if(depth>=1&&n.id)out.push(Number(n.id)); walk(n.left,depth+1); walk(n.right,depth+1); })(d&&d.subtree,0);
return out;
}
async function loadMsgUI(d){
const el=document.getElementById('dMsg');
let me=null;
@@ -327,7 +366,23 @@
let data;
try{data=await(await fetch('/api/public/msg-inbox')).json();}catch(e){el.innerHTML='<div class="empty">Could not load messages — refresh to retry.</div>';return;}
const mine=Number(me.id)===Number(d.id);
const banner=mine?'':`<div class="callout" style="margin-bottom:10px">You're signed in as <strong>#${me.id}</strong> — this inbox is yours. (You're viewing #${d.id}'s page; the "to" box is pre-filled for them.)</div>`;
let banner=mine?'':`<div class="callout" style="margin-bottom:10px">You're signed in as <strong>#${me.id}</strong> — this inbox is yours. (You're viewing #${d.id}'s page; the "to" box is pre-filled for them.)</div>`;
// The one place contact details genuinely matter: a message cannot be delivered
// to someone who has given no way to reach them. Asked here, never forced.
try{
const pr=await window.RMCProfile.status();
if(pr&&pr.signedIn!==false&&!(pr.profile&&pr.profile.complete)){
banner+=`<div class="callout" style="margin-bottom:10px;border-color:rgba(78,214,203,.45)"><strong>Messages reach you here only.</strong> Add an email and they reach you off the site too, plus a note whenever a payout lands. Optional, private, removable any time. <button id="msgAddContact" class="btn btn-teal btn-sm" style="margin-left:8px">Add mine</button></div>`;
}
const idsInOrg=orgPositionIds(d);
if(mine&&idsInOrg.length){
const rr=await (await fetch('/api/public/reach?ids='+idsInOrg.slice(0,2000).join(','))).json();
if(rr&&rr.total){
const pct=Math.round((rr.reachable/rr.total)*100);
banner+=`<div class="micro" style="margin:0 0 10px;color:var(--muted)"><strong style="color:var(--text)">${rr.reachable} of ${rr.total}</strong> in your org can be reached off the site (${pct}%). The rest only see a message if they open this page. Ask your two to add an email — it is the difference between a team you can talk to and one you cannot.</div>`;
}
}
}catch(e){}
const rows=(data.inbox||[]).map(m=>`<div class="pp-row" style="padding:9px 12px${m.read?'':';border-color:rgba(240,197,109,.55)'}"><div class="pp-icon">${m.org?'📣':'✉️'}</div><div class="pp-body"><strong>From #${m.fromId}</strong> <span class="pp-meta" style="display:inline">· ${new Date(m.ts).toLocaleString()}${m.org?' · team broadcast':''}${m.read?'':' · <strong style="color:var(--gold)">NEW</strong>'}</span><div style="white-space:pre-wrap;margin-top:4px">${esc(m.body)}</div></div></div>`).join('')||'<div class="empty">No messages yet.</div>';
const sent=(data.sent||[]).slice(0,3).map(m=>`<div class="micro" style="margin:3px 0">→ ${m.org?'whole team':'#'+m.toId} · ${new Date(m.ts).toLocaleString()}: ${esc(m.body.slice(0,90))}${m.body.length>90?'…':''}</div>`).join('');
el.innerHTML=banner+rows+
@@ -419,7 +474,6 @@
const sig=await eth.request({method:'personal_sign',params:[hex,account]});
const v=await(await fetch('/api/public/msg-verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:account,signature:sig})})).json();
if(!v.ok)throw new Error(v.error||'Verification failed.');
try{ if(window.RMCProfile)await window.RMCProfile.require({onlyForId:d&&d.id}); }catch(ge){}
loadMsgUI(d);
}catch(e){if(err)err.textContent=e.message||String(e);}
}
@@ -500,6 +554,9 @@
// to k+2, so the cost to REACH level L is index L-2. Scintilla (L1) is the
// entry level, not an upgrade.
const upc=(d.upgradeCosts&&d.upgradeCosts[d.tier===2?2:1])||[];
// Manson, 2026-09-17: showing all eight rungs can read as a required climb.
// It is not — you stop wherever you like, and NEXT is only ever a suggestion
// based on who is actually below you.
const ladder=LEVELS.map((nm,i)=>{
const L=i+1;let cls='nlv';
if(L<lvl)cls+=' done';else if(L===lvl)cls+=' you';else if(ns.kind==='upgrade'&&L===ns.nextLevel)cls+=' next';
@@ -524,7 +581,7 @@
head="You're at the top level 🏆";
body='Keep helping your team duplicate — every level they climb still pays up to you.';
}else{el.innerHTML='';return;}
el.innerHTML=`<div class="ns-card"><div class="ns-top"><div class="ns-eyebrow">Your plan</div>${badge}</div><div class="ns-h">${head}</div><p class="ns-p">${body}</p><div class="nladder">${ladder}</div><p class="micro" style="margin:10px 0 0"><a href="/how-pay-works#level-scale" style="color:var(--teal)">See what each level opens up →</a></p></div>`;
el.innerHTML=`<div class="ns-card"><div class="ns-top"><div class="ns-eyebrow">Your plan</div>${badge}</div><div class="ns-h">${head}</div><p class="ns-p">${body}</p><div class="nladder">${ladder}</div><p class="micro" style="margin:8px 0 0;color:var(--muted)">You stop wherever you like — there is no level you have to reach. NEXT is only a suggestion based on who is below you today.</p><p class="micro" style="margin:10px 0 0"><a href="/how-pay-works#level-scale" style="color:var(--teal)">See what each level opens up →</a></p></div>`;
}
function renderPipeline(d){
const el=document.getElementById('dPipeline');
@@ -781,5 +838,7 @@
if(id)load(id);
// Required member profile (username + verified email). No-ops for a visitor who
// has not proved they own a position: the API 401s and the gate never shows.
try{ if(window.RMCProfile&&id)window.RMCProfile.require({onlyForId:id}); }catch(e){}
// No automatic modal. Contact details are optional; the dashboard shows a
// dismissable card instead, and the inbox asks only when it actually needs one.
try{ if(window.RMCProfile&&id)maybeProfileCard(id); }catch(e){}
})();