Members can take it back off, and OPTIONAL is now impossible to miss

Three things, one of which we were quietly getting wrong.

REAL OPT-OUT. We told members "removable any time" in three separate places and there
was no way to remove anything. Same class of failure as the dead "Add mine" button:
copy written, mechanism never built. The profile card now offers Remove username,
Remove email and Remove everything, and profiles.remove() clears the value while never
touching the position. Adding it again later works exactly as before, so opting out is
not a one-way door.

It is a two-step inline confirm, not a native confirm() dialog. Browsers with "suppress
dialogs" switched on return false, which would have made Remove look broken in precisely
the way Add mine was broken. First tap arms and explains the consequence, second tap
does it, and it disarms itself after six seconds.

THE PROMISE WE WERE BREAKING. The payout mailer and the upgrade alerts read
member-alerts.json, NOT profiles.json. So a member who completed the new profile got
NOTHING, while the invitation card promised "a note the moment POL lands in your
wallet". Verifying a profile email now mirrors into member-alerts.json so every existing
alert path works, including the unsubscribe link, and removing the email clears both
stores so opting out actually stops the email.

MANSON'S HUGE ASTERISK. He asked for it to be bigger and bolder so nobody can say they
did not see it, and on a decentralized build that burden is ours, not the member's. One
gold badge now appears on the dashboard invitation, inside the dialog on every step, in
the inbox banner and on the profile card itself: "100% OPTIONAL - never required", with
the plain statement that the position, the payouts, the team and everything on the page
work exactly the same without it, nothing on chain depends on it, and it can be removed
again any time.

gate-e2e is 52 assertions, up from 38. The new ones prove one tap does NOT remove
anything, the second tap does, the server agrees the value is gone, an email-only
removal leaves the username alone, and the invitation reappears afterwards so the whole
thing is reversible. profiles-unit 28, signin-fallback 7, captions-e2e 158 all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-17 06:52:43 -05:00
parent 88a3ad1149
commit 2f4b70c886
5 changed files with 278 additions and 133 deletions
+54 -5
View File
@@ -333,9 +333,10 @@
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>'+
'Never shown to other members, never sold.</p>'+
OPT_BADGE+
'<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>';
'<button id="pcNo" class="btn btn-secondary btn-sm" style="margin-left:6px">No thanks</button>';
tabs.parentNode.insertBefore(box,tabs);
document.getElementById('pcGo').addEventListener('click',async function(){
await window.RMCProfile.prompt({onlyForId:id,reason:'alerts'});
@@ -372,7 +373,10 @@
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>`;
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. `+
`<button id="msgAddContact" class="btn btn-teal btn-sm" style="margin-left:8px">Add mine</button>`+
OPT_BADGE.replace('margin:0 0 14px','margin:11px 0 0')+`</div>`;
}
const idsInOrg=orgPositionIds(d);
if(mine&&idsInOrg.length){
@@ -689,6 +693,15 @@
L.push(`See it live and get a red alert the moment you'd miss one: rmcircle.team/my/${d.id}`);
return L.join('\n');
}
// Manson asked for a HUGE asterisk so nobody can say they did not see it, and he is
// right: on a decentralized build the burden is on us to make "optional" unmissable,
// not on the member to find it in grey 11px type. Same badge everywhere it comes up.
const OPT_BADGE='<div style="display:flex;gap:10px;align-items:flex-start;margin:0 0 14px;padding:11px 13px;'+
'border:1px solid rgba(240,197,109,.55);border-radius:12px;background:rgba(240,197,109,.08)">'+
'<span style="font-size:19px;line-height:1.1">✳️</span>'+
'<div><div style="font-weight:800;color:var(--gold);font-size:15px;letter-spacing:.01em">100% OPTIONAL — never required</div>'+
'<div class="micro" style="color:var(--text);margin-top:3px;line-height:1.55">Your position, your payouts, your team and everything on this page work exactly the same without it. '+
'Nothing on the blockchain depends on it. Nobody is ever blocked, and you can remove it again any time.</div></div></div>';
async function renderAlerts(d){
const el=document.getElementById('dAlerts');
if(!el)return;
@@ -701,16 +714,52 @@
const hd=document.getElementById('dAlertsHead'),intro=document.getElementById('dAlertsIntro');
if(hd)hd.textContent='Your member profile';
if(intro)intro.innerHTML='How your team leader reaches you, and where your payout alerts go. Your email is never shown to other members and never sold.';
el.innerHTML=`<div style="display:flex;flex-wrap:wrap;gap:10px 22px;align-items:center;margin:0 0 12px">`+
el.innerHTML=OPT_BADGE+
`<div style="display:flex;flex-wrap:wrap;gap:10px 22px;align-items:center;margin:0 0 12px">`+
`<div><div class="micro" style="color:var(--muted)">Username</div><div style="font-weight:700">@${esc(pr.profile.username)}</div></div>`+
`<div><div class="micro" style="color:var(--muted)">Email <span style="color:var(--ok)">✓ confirmed</span></div><div style="font-weight:700">${esc(pr.profile.email)}</div></div>`+
`</div><button id="pgEditUser" class="btn btn-secondary btn-sm">Change username</button> `+
`<button id="pgEditMail" class="btn btn-secondary btn-sm" style="margin-left:6px">Change email</button>`+
`<p class="micro" style="margin:10px 0 0;color:var(--muted)">Payout alerts go to this address. Changing it here changes both.</p>`;
// Marty + Manson, 2026-09-17: "removable any time" was written in three places
// with no way to actually do it. These are that promise, kept.
`<div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--line)">`+
`<div class="micro" style="color:var(--muted);margin-bottom:6px">Changed your mind? Take any of it back off, any time.</div>`+
`<button id="pgDropUser" class="btn btn-secondary btn-sm">Remove username</button> `+
`<button id="pgDropMail" class="btn btn-secondary btn-sm" style="margin-left:6px">Remove email</button> `+
`<button id="pgDropAll" class="btn btn-secondary btn-sm" style="margin-left:6px">Remove everything</button>`+
`<div id="pgDropMsg" class="micro" style="margin-top:8px"></div></div>`+
`<p class="micro" style="margin:10px 0 0;color:var(--muted)">Payout alerts go to this address. Changing it here changes both, and removing it stops those emails.</p>`;
const again=()=>renderAlerts(d);
const bu=document.getElementById('pgEditUser'),bm=document.getElementById('pgEditMail');
if(bu)bu.addEventListener('click',async()=>{await window.RMCProfile.edit('username');again();});
if(bm)bm.addEventListener('click',async()=>{await window.RMCProfile.edit('email');again();});
// Two-step inline confirm instead of a native confirm() dialog. Browsers with
// "suppress dialogs" switched on return false, which would make Remove look
// broken exactly the way the dead "Add mine" button did (Marty, 2026-09-17).
const drop=function(btn,what,label){
if(!btn)return;
let armed=false,timer=null;
const original=btn.textContent;
const msg=document.getElementById('pgDropMsg');
btn.addEventListener('click',async function(){
if(!armed){
armed=true; btn.textContent='Tap again to remove';
btn.style.borderColor='var(--danger)'; btn.style.color='var(--danger)';
if(msg){msg.textContent='Removing your '+label+' changes nothing about your position, your payouts or anything else on this page, and you can add it again whenever you like.';msg.style.color='var(--muted)';}
timer=setTimeout(function(){armed=false;btn.textContent=original;btn.style.borderColor='';btn.style.color='';if(msg)msg.textContent='';},6000);
return;
}
clearTimeout(timer); armed=false; btn.disabled=true; btn.textContent='Removing…';
try{
const r=await(await fetch('/api/public/profile/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({what:what})})).json();
if(r&&r.error){ if(msg){msg.textContent=r.error;msg.style.color='var(--danger)';} btn.disabled=false; btn.textContent=original; return; }
again();
}catch(e){ if(msg){msg.textContent='Could not remove that — try again.';msg.style.color='var(--danger)';} btn.disabled=false; btn.textContent=original; }
});
};
drop(document.getElementById('pgDropUser'),'username','username');
drop(document.getElementById('pgDropMail'),'email','email address');
drop(document.getElementById('pgDropAll'),'all','username and email');
return;
}
}catch(e){}
+9
View File
@@ -58,6 +58,15 @@
if (!showSteps) return card;
card.appendChild(el('div', 'text-align:center;letter-spacing:2px;text-transform:uppercase;font-size:11px;font-weight:700;color:' + GOLD + ';margin-bottom:8px;',
'Position #' + esc(state.id) + ' · step ' + step + ' of 2'));
// Manson asked for a HUGE asterisk so nobody can claim they did not see it. On a
// decentralized build the burden is on us to make "optional" unmissable, and this
// dialog is the one place a member might feel cornered (Marty + Manson, 2026-09-17).
card.appendChild(el('div', 'display:flex;gap:9px;align-items:flex-start;margin:0 0 14px;padding:10px 12px;' +
'border:1px solid rgba(240,197,109,.55);border-radius:12px;background:rgba(240,197,109,.09);',
'<span style="font-size:18px;line-height:1.1">✳️</span><div>' +
'<div style="font-weight:800;color:' + GOLD + ';font-size:14px">100% OPTIONAL — never required</div>' +
'<div style="font-size:12.5px;line-height:1.55;color:#c8d6e5;margin-top:2px">Close this any time. Your position, your payouts and your team are not affected in any way, ' +
'and you can remove whatever you add later.</div></div>'));
return card;
}
function note(text, color) {