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:
+28
-1
@@ -146,6 +146,33 @@ function verifyEmail(id, rawCode) {
|
|||||||
return { ok: true, profile: pub(p) };
|
return { ok: true, profile: pub(p) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- opt out: take a detail back off ----
|
||||||
|
// We tell members "removable any time" in three places. That has to be true, and it has
|
||||||
|
// to be reversible: removing clears the value, never the position (Marty + Manson,
|
||||||
|
// 2026-09-17). The caller is responsible for also clearing member-alerts.json, because
|
||||||
|
// the payout mailer reads that file, not this one.
|
||||||
|
function remove(id, what) {
|
||||||
|
const p = db.byId[norm(id)];
|
||||||
|
if (!p) return { ok: true, profile: pub({ id: Number(norm(id)) }), removed: [] };
|
||||||
|
const removed = [];
|
||||||
|
if (what === 'email' || what === 'all') {
|
||||||
|
if (p.email || p.emailVerified) removed.push('email');
|
||||||
|
p.email = null; p.emailVerified = false; delete p.emailVerifiedAt; delete p.seededFrom;
|
||||||
|
codes.delete(norm(id)); // kill any half-finished code flow too
|
||||||
|
}
|
||||||
|
if (what === 'username' || what === 'all') {
|
||||||
|
if (p.username) removed.push('username');
|
||||||
|
p.username = null;
|
||||||
|
}
|
||||||
|
if (what === 'all') {
|
||||||
|
if (p.telegramId) removed.push('telegram');
|
||||||
|
p.telegramId = null;
|
||||||
|
}
|
||||||
|
if (!removed.length && what !== 'all' && what !== 'email' && what !== 'username') return { error: 'Nothing to remove.' };
|
||||||
|
p.updated = Date.now(); save();
|
||||||
|
return { ok: true, profile: pub(p), removed };
|
||||||
|
}
|
||||||
|
|
||||||
// ---- reach: who can actually be contacted, and how ----
|
// ---- reach: who can actually be contacted, and how ----
|
||||||
function contactFor(id) {
|
function contactFor(id) {
|
||||||
const p = db.byId[norm(id)];
|
const p = db.byId[norm(id)];
|
||||||
@@ -180,5 +207,5 @@ function adminList() { return Object.values(db.byId).map(pub).sort((a, b) => a.i
|
|||||||
// local testing only: the server exposes this outside production, never on the live site
|
// local testing only: the server exposes this outside production, never on the live site
|
||||||
function peekCode(id) { const st = codes.get(norm(id)); return st ? st.code : null; }
|
function peekCode(id) { const st = codes.get(norm(id)); return st ? st.code : null; }
|
||||||
|
|
||||||
module.exports = { init, get, pub, status, complete, isComplete, setUsername, byUsername, suggest, peekCode, reachFor,
|
module.exports = { init, get, pub, status, complete, isComplete, setUsername, byUsername, suggest, peekCode, reachFor, remove,
|
||||||
startEmail, verifyEmail, contactFor, setTelegram, positionsFor, coverage, adminList, ensure, USER_RE, EMAIL_RE };
|
startEmail, verifyEmail, contactFor, setTelegram, positionsFor, coverage, adminList, ensure, USER_RE, EMAIL_RE };
|
||||||
|
|||||||
+54
-5
@@ -333,9 +333,10 @@
|
|||||||
box.style.cssText='margin:0 0 18px;border-color:rgba(78,214,203,.4)';
|
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>'+
|
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. '+
|
'<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="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);
|
tabs.parentNode.insertBefore(box,tabs);
|
||||||
document.getElementById('pcGo').addEventListener('click',async function(){
|
document.getElementById('pcGo').addEventListener('click',async function(){
|
||||||
await window.RMCProfile.prompt({onlyForId:id,reason:'alerts'});
|
await window.RMCProfile.prompt({onlyForId:id,reason:'alerts'});
|
||||||
@@ -372,7 +373,10 @@
|
|||||||
try{
|
try{
|
||||||
const pr=await window.RMCProfile.status();
|
const pr=await window.RMCProfile.status();
|
||||||
if(pr&&pr.signedIn!==false&&!(pr.profile&&pr.profile.complete)){
|
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);
|
const idsInOrg=orgPositionIds(d);
|
||||||
if(mine&&idsInOrg.length){
|
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}`);
|
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');
|
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){
|
async function renderAlerts(d){
|
||||||
const el=document.getElementById('dAlerts');
|
const el=document.getElementById('dAlerts');
|
||||||
if(!el)return;
|
if(!el)return;
|
||||||
@@ -701,16 +714,52 @@
|
|||||||
const hd=document.getElementById('dAlertsHead'),intro=document.getElementById('dAlertsIntro');
|
const hd=document.getElementById('dAlertsHead'),intro=document.getElementById('dAlertsIntro');
|
||||||
if(hd)hd.textContent='Your member profile';
|
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.';
|
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)">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><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> `+
|
`</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>`+
|
`<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 again=()=>renderAlerts(d);
|
||||||
const bu=document.getElementById('pgEditUser'),bm=document.getElementById('pgEditMail');
|
const bu=document.getElementById('pgEditUser'),bm=document.getElementById('pgEditMail');
|
||||||
if(bu)bu.addEventListener('click',async()=>{await window.RMCProfile.edit('username');again();});
|
if(bu)bu.addEventListener('click',async()=>{await window.RMCProfile.edit('username');again();});
|
||||||
if(bm)bm.addEventListener('click',async()=>{await window.RMCProfile.edit('email');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;
|
return;
|
||||||
}
|
}
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
|
|||||||
@@ -58,6 +58,15 @@
|
|||||||
if (!showSteps) return card;
|
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;',
|
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'));
|
'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;
|
return card;
|
||||||
}
|
}
|
||||||
function note(text, color) {
|
function note(text, color) {
|
||||||
|
|||||||
+162
-127
@@ -1,127 +1,162 @@
|
|||||||
// End-to-end QA of the RM Circle OPTIONAL member profile (Marty + Manson, 2026-09-17).
|
// End-to-end QA of the RM Circle OPTIONAL member profile (Marty + Manson, 2026-09-17).
|
||||||
//
|
//
|
||||||
// The rule this suite defends: contact details are NEVER required. Nothing about
|
// The rule this suite defends: contact details are NEVER required. Nothing about
|
||||||
// holding a position, getting paid, reading the org, the training or the tools may
|
// holding a position, getting paid, reading the org, the training or the tools may
|
||||||
// depend on them. The only place they are asked for is the inbox, where a message
|
// depend on them. The only place they are asked for is the inbox, where a message
|
||||||
// cannot be delivered without them, and even there it is an invitation.
|
// cannot be delivered without them, and even there it is an invitation.
|
||||||
//
|
//
|
||||||
// Run: LOCAL=... TOKEN=<msid for 21> TOKEN2=<msid for 49> node qa/gate-e2e.mjs
|
// Run: LOCAL=... TOKEN=<msid for 21> TOKEN2=<msid for 49> node qa/gate-e2e.mjs
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
const PW = 'D:/Projects/MarketingAgent/qa-tester/node_modules/playwright';
|
const PW = 'D:/Projects/MarketingAgent/qa-tester/node_modules/playwright';
|
||||||
const { chromium } = (await import(pathToFileURL(PW + '/index.js').href)).default;
|
const { chromium } = (await import(pathToFileURL(PW + '/index.js').href)).default;
|
||||||
const B = process.env.LOCAL || 'http://127.0.0.1:3399';
|
const B = process.env.LOCAL || 'http://127.0.0.1:3399';
|
||||||
const TOKEN = process.env.TOKEN; // position 21, no profile yet
|
const TOKEN = process.env.TOKEN; // position 21, no profile yet
|
||||||
const TOKEN2 = process.env.TOKEN2; // position 49, seeded email, no username
|
const TOKEN2 = process.env.TOKEN2; // position 49, seeded email, no username
|
||||||
const ok = [], bad = [];
|
const ok = [], bad = [];
|
||||||
const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); };
|
const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); };
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
const ctxFor = async (tok, mobile) => {
|
const ctxFor = async (tok, mobile) => {
|
||||||
const c = await browser.newContext(mobile ? { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true } : { viewport: { width: 1280, height: 950 } });
|
const c = await browser.newContext(mobile ? { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true } : { viewport: { width: 1280, height: 950 } });
|
||||||
// The event flyer and the upgrade promo are full-screen overlays that legitimately
|
// The event flyer and the upgrade promo are full-screen overlays that legitimately
|
||||||
// cover /my once per browser/session. They are not what this suite tests, and they
|
// cover /my once per browser/session. They are not what this suite tests, and they
|
||||||
// swallow clicks, so mark them already-seen instead of racing their fade-out.
|
// swallow clicks, so mark them already-seen instead of racing their fade-out.
|
||||||
await c.addInitScript(() => {
|
await c.addInitScript(() => {
|
||||||
const g = Storage.prototype.getItem;
|
const g = Storage.prototype.getItem;
|
||||||
Storage.prototype.getItem = function (k) {
|
Storage.prototype.getItem = function (k) {
|
||||||
if (/^rmc-promo-/.test(k)) return 'seen';
|
if (/^rmc-promo-/.test(k)) return 'seen';
|
||||||
if (/^rmc-announce-/.test(k)) return 'done';
|
if (/^rmc-announce-/.test(k)) return 'done';
|
||||||
return g.call(this, k);
|
return g.call(this, k);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (tok) await c.addCookies([{ name: 'ctb.msid', value: tok, url: B }]);
|
if (tok) await c.addCookies([{ name: 'ctb.msid', value: tok, url: B }]);
|
||||||
return c;
|
return c;
|
||||||
};
|
};
|
||||||
const modalOpen = p => p.evaluate(() => !!document.querySelector('#pgCard'));
|
const modalOpen = p => p.evaluate(() => !!document.querySelector('#pgCard'));
|
||||||
const cardText = p => p.evaluate(() => { const c = document.querySelector('#pgCard'); return c ? c.innerText.replace(/\s+/g, ' ') : ''; });
|
const cardText = p => p.evaluate(() => { const c = document.querySelector('#pgCard'); return c ? c.innerText.replace(/\s+/g, ' ') : ''; });
|
||||||
const openDash = async p => { await p.evaluate(() => { const b = document.querySelector('.mp-tab[data-tab="dash"]'); if (b) b.click(); }); await p.waitForTimeout(1800); };
|
const openDash = async p => { await p.evaluate(() => { const b = document.querySelector('.mp-tab[data-tab="dash"]'); if (b) b.click(); }); await p.waitForTimeout(1800); };
|
||||||
|
|
||||||
// ---------- 1. nothing is forced ----------
|
// ---------- 1. nothing is forced ----------
|
||||||
const ctx = await ctxFor(TOKEN);
|
const ctx = await ctxFor(TOKEN);
|
||||||
const p = await ctx.newPage();
|
const p = await ctx.newPage();
|
||||||
await p.goto(B + '/my/21', { waitUntil: 'networkidle' }); await p.waitForTimeout(2600);
|
await p.goto(B + '/my/21', { waitUntil: 'networkidle' }); await p.waitForTimeout(2600);
|
||||||
t('NO automatic modal on the member\'s own page', !(await modalOpen(p)));
|
t('NO automatic modal on the member\'s own page', !(await modalOpen(p)));
|
||||||
await openDash(p);
|
await openDash(p);
|
||||||
t('still no modal after opening the dashboard', !(await modalOpen(p)));
|
t('still no modal after opening the dashboard', !(await modalOpen(p)));
|
||||||
const body1 = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' '));
|
const body1 = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' '));
|
||||||
t('a dismissable invitation card is shown instead', /Get a note when you get paid/i.test(body1), body1.slice(0, 120));
|
t('a dismissable invitation card is shown instead', /Get a note when you get paid/i.test(body1), body1.slice(0, 120));
|
||||||
t('the card says it is optional', /optional/i.test(body1));
|
t('the card says it is optional', /optional/i.test(body1));
|
||||||
t('the card promises nothing else changes', /work exactly the same without it/i.test(body1));
|
t('the card promises nothing else changes', /work exactly the same without it/i.test(body1));
|
||||||
t('"Not now" is offered', await p.evaluate(() => !!document.getElementById('pcNo')));
|
t('"Not now" is offered', await p.evaluate(() => !!document.getElementById('pcNo')));
|
||||||
|
|
||||||
// declining costs nothing and snoozes
|
// declining costs nothing and snoozes
|
||||||
await p.click('#pcNo'); await p.waitForTimeout(600);
|
await p.click('#pcNo'); await p.waitForTimeout(600);
|
||||||
t('declining removes the card', !(await p.evaluate(() => !!document.getElementById('pcNo'))));
|
t('declining removes the card', !(await p.evaluate(() => !!document.getElementById('pcNo'))));
|
||||||
t('declining leaves the dashboard fully usable', (await p.evaluate(() => document.body.innerText)).length > 500);
|
t('declining leaves the dashboard fully usable', (await p.evaluate(() => document.body.innerText)).length > 500);
|
||||||
await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p);
|
await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p);
|
||||||
t('the card stays away after declining (snoozed)', !(await p.evaluate(() => !!document.getElementById('pcNo'))));
|
t('the card stays away after declining (snoozed)', !(await p.evaluate(() => !!document.getElementById('pcNo'))));
|
||||||
t('and still no modal', !(await modalOpen(p)));
|
t('and still no modal', !(await modalOpen(p)));
|
||||||
|
|
||||||
// ---------- 2. opting in works, and the dialog can be abandoned ----------
|
// ---------- 2. opting in works, and the dialog can be abandoned ----------
|
||||||
await p.evaluate(() => { try { localStorage.removeItem('rmc.profileSnooze'); } catch (e) {} });
|
await p.evaluate(() => { try { localStorage.removeItem('rmc.profileSnooze'); } catch (e) {} });
|
||||||
await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p);
|
await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p);
|
||||||
t('the card returns once the snooze is cleared', await p.evaluate(() => !!document.getElementById('pcGo')));
|
t('the card returns once the snooze is cleared', await p.evaluate(() => !!document.getElementById('pcGo')));
|
||||||
await p.click('#pcGo'); await p.waitForTimeout(1000);
|
await p.click('#pcGo'); await p.waitForTimeout(1000);
|
||||||
t('the dialog opens on request', await modalOpen(p));
|
t('the dialog opens on request', await modalOpen(p));
|
||||||
t('the dialog can always be closed', await p.evaluate(() => !!document.getElementById('pgClose')));
|
t('the dialog can always be closed', await p.evaluate(() => !!document.getElementById('pgClose')));
|
||||||
t('it opens at the username step, worded as optional', /changeable any time/i.test(await cardText(p)), await cardText(p));
|
t('it opens at the username step, worded as optional', /changeable any time/i.test(await cardText(p)), await cardText(p));
|
||||||
await p.click('#pgClose'); await p.waitForTimeout(600);
|
await p.click('#pgClose'); await p.waitForTimeout(600);
|
||||||
t('abandoning the dialog is allowed', !(await modalOpen(p)));
|
t('abandoning the dialog is allowed', !(await modalOpen(p)));
|
||||||
|
|
||||||
// complete it for real
|
// complete it for real
|
||||||
await p.click('#pcGo'); await p.waitForTimeout(900);
|
await p.click('#pcGo'); await p.waitForTimeout(900);
|
||||||
let devCode = null;
|
let devCode = null;
|
||||||
p.on('response', async r => { if (r.url().includes('/profile/email-start')) { try { const j = await r.json(); if (j.devCode) devCode = j.devCode; } catch (e) {} } });
|
p.on('response', async r => { if (r.url().includes('/profile/email-start')) { try { const j = await r.json(); if (j.devCode) devCode = j.devCode; } catch (e) {} } });
|
||||||
await p.fill('#pgUser', 'optin21'); await p.click('#pgUserSave'); await p.waitForTimeout(1100);
|
await p.fill('#pgUser', 'optin21'); await p.click('#pgUserSave'); await p.waitForTimeout(1100);
|
||||||
// the regression that matters: saving a username must ADVANCE to the email step,
|
// the regression that matters: saving a username must ADVANCE to the email step,
|
||||||
// not close the dialog. The dismissable flag used to double as "single-field edit".
|
// not close the dialog. The dismissable flag used to double as "single-field edit".
|
||||||
t('saving the username advances to the email step', await p.evaluate(() => !!document.getElementById('pgEmail')), await cardText(p));
|
t('saving the username advances to the email step', await p.evaluate(() => !!document.getElementById('pgEmail')), await cardText(p));
|
||||||
t('the email step leads with payout alerts, not messaging', /payout lands in your wallet/i.test(await cardText(p)), await cardText(p));
|
t('the email step leads with payout alerts, not messaging', /payout lands in your wallet/i.test(await cardText(p)), await cardText(p));
|
||||||
await p.fill('#pgEmail', 'optin@example.com'); await p.click('#pgMailSend'); await p.waitForTimeout(1400);
|
await p.fill('#pgEmail', 'optin@example.com'); await p.click('#pgMailSend'); await p.waitForTimeout(1400);
|
||||||
await p.fill('#pgCode', devCode || '000000'); await p.click('#pgCodeConfirm'); await p.waitForTimeout(1500);
|
await p.fill('#pgCode', devCode || '000000'); await p.click('#pgCodeConfirm'); await p.waitForTimeout(1500);
|
||||||
t('opting in completes', /all set, @optin21/i.test(await cardText(p)) || !(await modalOpen(p)), await cardText(p));
|
t('opting in completes', /all set, @optin21/i.test(await cardText(p)) || !(await modalOpen(p)), await cardText(p));
|
||||||
await p.waitForTimeout(3200);
|
await p.waitForTimeout(3200);
|
||||||
await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p);
|
await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p);
|
||||||
const body2 = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' '));
|
const body2 = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' '));
|
||||||
t('the invitation card is gone once done', !/Get a note when you get paid/i.test(body2));
|
t('the invitation card is gone once done', !/Get a note when you get paid/i.test(body2));
|
||||||
t('the profile card shows their details', /Your member profile/.test(body2) && /@optin21/.test(body2), body2.slice(0, 120));
|
t('the profile card shows their details', /Your member profile/.test(body2) && /@optin21/.test(body2), body2.slice(0, 120));
|
||||||
t('they can still change it later', await p.evaluate(() => !!document.getElementById('pgEditUser')));
|
t('they can still change it later', await p.evaluate(() => !!document.getElementById('pgEditUser')));
|
||||||
|
|
||||||
// ---------- 3. the inbox asks only where it matters ----------
|
// ---------- 2b. OPTIONAL is unmissable, and removal actually works ----------
|
||||||
const ctx2 = await ctxFor(TOKEN2); const p3 = await ctx2.newPage();
|
// Manson asked for a HUGE asterisk so nobody can say they did not see it, and Marty
|
||||||
await p3.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p3.waitForTimeout(2400); await openDash(p3);
|
// asked for a real opt-out. "Removable any time" was written in three places with no
|
||||||
t('no modal for the member without contact details', !(await modalOpen(p3)));
|
// way to do it, which is the same class of failure as the dead "Add mine" button.
|
||||||
const body3 = await p3.evaluate(() => document.body.innerText.replace(/\s+/g, ' '));
|
const body2b = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' '));
|
||||||
t('the inbox explains why it needs an address', /Messages reach you here only/i.test(body3), body3.slice(0, 140));
|
t('the profile card shouts that it is optional', /100% OPTIONAL/i.test(body2b), body2b.slice(0, 120));
|
||||||
t('the inbox ask is phrased as optional', /Optional, private, removable/i.test(body3));
|
t('and says nothing is affected without it', /work exactly the same without it/i.test(body2b));
|
||||||
// Marty, 2026-09-17: this button shipped with NO click handler and did nothing at all.
|
t('Remove username is offered', await p.evaluate(() => !!document.getElementById('pgDropUser')));
|
||||||
// Rendering it is not the test; it has to actually open the dialog.
|
t('Remove email is offered', await p.evaluate(() => !!document.getElementById('pgDropMail')));
|
||||||
t('the inbox "Add mine" button exists', await p3.evaluate(() => !!document.getElementById('msgAddContact')));
|
t('Remove everything is offered', await p.evaluate(() => !!document.getElementById('pgDropAll')));
|
||||||
await p3.click('#msgAddContact').catch(() => {});
|
|
||||||
await p3.waitForTimeout(1400);
|
// one tap ARMS, it must not remove anything yet
|
||||||
t('"Add mine" actually opens the profile dialog', await modalOpen(p3), 'button did nothing');
|
await p.click('#pgDropMail'); await p.waitForTimeout(500);
|
||||||
t('and that dialog uses the inbox wording', /inbox needs somewhere to reach you|reach you off the site/i.test(await cardText(p3)) || /username/i.test(await cardText(p3)), await cardText(p3));
|
t('one tap only arms, nothing removed yet', /Tap again to remove/i.test(await p.evaluate(() => document.getElementById('pgDropMail').textContent)));
|
||||||
t('and it can be closed', await p3.evaluate(() => !!document.getElementById('pgClose')));
|
t('and it explains the consequence before the second tap',
|
||||||
await p3.click('#pgClose').catch(() => {}); await p3.waitForTimeout(500);
|
/changes nothing about your position/i.test(await p.evaluate(() => (document.getElementById('pgDropMsg') || {}).textContent || '')));
|
||||||
t('closing it leaves the inbox usable', !(await modalOpen(p3)));
|
const stillThere = await p.evaluate(() => document.body.innerText);
|
||||||
|
t('the email is still on file after one tap', /optin@example\.com/.test(stillThere));
|
||||||
// ---------- 4. nothing changed for visitors or shared links ----------
|
|
||||||
const anon = await ctxFor(null); const p4 = await anon.newPage();
|
// second tap removes it, for real, in the store
|
||||||
const anon401 = [];
|
await p.click('#pgDropMail'); await p.waitForTimeout(2500);
|
||||||
p4.on('response', r => { if (r.status() === 401 && /\/profile|\/reach/.test(r.url())) anon401.push(r.url()); });
|
const afterDrop = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' '));
|
||||||
for (const u of ['/my/21', '/my/49', '/join/21', '/fast-start?id=21', '/generation-pay?id=21', '/flyers?id=21']) {
|
t('second tap actually removes the email', !/optin@example\.com/.test(afterDrop), afterDrop.slice(0, 140));
|
||||||
await p4.goto(B + u, { waitUntil: 'domcontentloaded' }); await p4.waitForTimeout(1500);
|
const st = await p.evaluate(async () => (await (await fetch('/api/public/profile')).json()));
|
||||||
const txt = await p4.evaluate(() => document.body.innerText);
|
t('the server agrees the email is gone', st && st.profile && !st.profile.email, JSON.stringify(st && st.profile));
|
||||||
t('visitor: ' + u + ' renders, no prompt of any kind', !(await modalOpen(p4)) && !/Get a note when you get paid/i.test(txt) && txt.length > 300, 'len ' + txt.length);
|
t('the username survived an email-only removal', st && st.profile && st.profile.username === 'optin21', JSON.stringify(st && st.profile));
|
||||||
}
|
t('removal drops them back to incomplete', st && st.profile && !st.profile.complete);
|
||||||
t('visitor triggers no 401s', anon401.length === 0, anon401.join(','));
|
|
||||||
|
// and it is reversible: the invitation comes back
|
||||||
// ---------- 5. phone ----------
|
await p.evaluate(() => { try { localStorage.removeItem('rmc.profileSnooze'); } catch (e) {} });
|
||||||
const ctxM = await ctxFor(TOKEN2, true); const p5 = await ctxM.newPage();
|
await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p);
|
||||||
await p5.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p5.waitForTimeout(2400); await openDash(p5);
|
t('after opting out the invitation returns, so it is reversible', await p.evaluate(() => !!document.getElementById('pcGo')));
|
||||||
t('no forced modal on a phone', !(await modalOpen(p5)));
|
|
||||||
t('no horizontal scroll on a phone', await p5.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 2));
|
// ---------- 3. the inbox asks only where it matters ----------
|
||||||
|
const ctx2 = await ctxFor(TOKEN2); const p3 = await ctx2.newPage();
|
||||||
console.log('PASS ' + ok.length);
|
await p3.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p3.waitForTimeout(2400); await openDash(p3);
|
||||||
for (const b of bad) console.log('FAIL ' + b);
|
t('no modal for the member without contact details', !(await modalOpen(p3)));
|
||||||
await browser.close();
|
const body3 = await p3.evaluate(() => document.body.innerText.replace(/\s+/g, ' '));
|
||||||
process.exit(bad.length ? 1 : 0);
|
t('the inbox explains why it needs an address', /Messages reach you here only/i.test(body3), body3.slice(0, 140));
|
||||||
|
// Manson's ask: unmissable, not a grey aside. The badge is the phrasing now.
|
||||||
|
t('the inbox ask carries the loud OPTIONAL badge', /100% OPTIONAL/i.test(body3), body3.slice(0, 200));
|
||||||
|
t('and the inbox spells out that nothing else changes', /work exactly the same without it/i.test(body3));
|
||||||
|
// Marty, 2026-09-17: this button shipped with NO click handler and did nothing at all.
|
||||||
|
// Rendering it is not the test; it has to actually open the dialog.
|
||||||
|
t('the inbox "Add mine" button exists', await p3.evaluate(() => !!document.getElementById('msgAddContact')));
|
||||||
|
await p3.click('#msgAddContact').catch(() => {});
|
||||||
|
await p3.waitForTimeout(1400);
|
||||||
|
t('"Add mine" actually opens the profile dialog', await modalOpen(p3), 'button did nothing');
|
||||||
|
t('and that dialog uses the inbox wording', /inbox needs somewhere to reach you|reach you off the site/i.test(await cardText(p3)) || /username/i.test(await cardText(p3)), await cardText(p3));
|
||||||
|
t('and it can be closed', await p3.evaluate(() => !!document.getElementById('pgClose')));
|
||||||
|
await p3.click('#pgClose').catch(() => {}); await p3.waitForTimeout(500);
|
||||||
|
t('closing it leaves the inbox usable', !(await modalOpen(p3)));
|
||||||
|
|
||||||
|
// ---------- 4. nothing changed for visitors or shared links ----------
|
||||||
|
const anon = await ctxFor(null); const p4 = await anon.newPage();
|
||||||
|
const anon401 = [];
|
||||||
|
p4.on('response', r => { if (r.status() === 401 && /\/profile|\/reach/.test(r.url())) anon401.push(r.url()); });
|
||||||
|
for (const u of ['/my/21', '/my/49', '/join/21', '/fast-start?id=21', '/generation-pay?id=21', '/flyers?id=21']) {
|
||||||
|
await p4.goto(B + u, { waitUntil: 'domcontentloaded' }); await p4.waitForTimeout(1500);
|
||||||
|
const txt = await p4.evaluate(() => document.body.innerText);
|
||||||
|
t('visitor: ' + u + ' renders, no prompt of any kind', !(await modalOpen(p4)) && !/Get a note when you get paid/i.test(txt) && txt.length > 300, 'len ' + txt.length);
|
||||||
|
}
|
||||||
|
t('visitor triggers no 401s', anon401.length === 0, anon401.join(','));
|
||||||
|
|
||||||
|
// ---------- 5. phone ----------
|
||||||
|
const ctxM = await ctxFor(TOKEN2, true); const p5 = await ctxM.newPage();
|
||||||
|
await p5.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p5.waitForTimeout(2400); await openDash(p5);
|
||||||
|
t('no forced modal on a phone', !(await modalOpen(p5)));
|
||||||
|
t('no horizontal scroll on a phone', await p5.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 2));
|
||||||
|
|
||||||
|
console.log('PASS ' + ok.length);
|
||||||
|
for (const b of bad) console.log('FAIL ' + b);
|
||||||
|
await browser.close();
|
||||||
|
process.exit(bad.length ? 1 : 0);
|
||||||
|
|||||||
@@ -1486,9 +1486,34 @@ async function handleApi(req,res,pathname){
|
|||||||
if(!s)return json(res,401,{error:'Not signed in.'});
|
if(!s)return json(res,401,{error:'Not signed in.'});
|
||||||
const b=await bodyJson(req).catch(()=>null);
|
const b=await bodyJson(req).catch(()=>null);
|
||||||
const r=profiles.verifyEmail(s.id,b&&b.code);
|
const r=profiles.verifyEmail(s.id,b&&b.code);
|
||||||
|
// The invitation promises "a note the moment a payout lands". The payout mailer and
|
||||||
|
// the upgrade alerts read member-alerts.json, NOT profiles.json, so a member who only
|
||||||
|
// ever completed the new profile was getting nothing and we were breaking that promise
|
||||||
|
// (found 2026-09-17). Mirror the verified address across so every existing alert path,
|
||||||
|
// including the unsubscribe link, just works.
|
||||||
|
if(r.ok&&r.profile&&r.profile.email){
|
||||||
|
try{ const ma=getMemberAlerts(); ma[s.id]={email:r.profile.email,ts:new Date().toISOString()}; saveMemberAlerts(ma); }
|
||||||
|
catch(e){ console.error('alert mirror',e.message); }
|
||||||
|
}
|
||||||
if(r.ok)console.log('profile complete for position #'+s.id);
|
if(r.ok)console.log('profile complete for position #'+s.id);
|
||||||
return json(res,r.error?400:200,r);
|
return json(res,r.error?400:200,r);
|
||||||
}
|
}
|
||||||
|
// Opting back out. We tell members "removable any time" in three places, so this has to
|
||||||
|
// exist and has to clear BOTH stores, or they keep getting email after opting out.
|
||||||
|
if(req.method==='POST'&&pathname==='/api/public/profile/remove'){
|
||||||
|
const s=messages.authFromCookie(req);
|
||||||
|
if(!s)return json(res,401,{error:'Not signed in.'});
|
||||||
|
const b=await bodyJson(req).catch(()=>null);
|
||||||
|
const what=String(b&&b.what||'');
|
||||||
|
if(!['email','username','all'].includes(what))return json(res,400,{error:'Say what to remove.'});
|
||||||
|
const r=profiles.remove(s.id,what);
|
||||||
|
if(r.ok&&(what==='email'||what==='all')){
|
||||||
|
try{ const ma=getMemberAlerts(); if(ma[s.id]){delete ma[s.id];saveMemberAlerts(ma);} }
|
||||||
|
catch(e){ console.error('alert unmirror',e.message); }
|
||||||
|
}
|
||||||
|
if(r.ok)console.log('profile removed ('+what+') for position #'+s.id);
|
||||||
|
return json(res,r.error?400:200,r);
|
||||||
|
}
|
||||||
if(req.method==='GET'&&pathname==='/api/admin/profiles'){
|
if(req.method==='GET'&&pathname==='/api/admin/profiles'){
|
||||||
if(!requireAdmin(req,res))return;
|
if(!requireAdmin(req,res))return;
|
||||||
return json(res,200,{coverage:profiles.coverage(),profiles:profiles.adminList()});
|
return json(res,200,{coverage:profiles.coverage(),profiles:profiles.adminList()});
|
||||||
|
|||||||
Reference in New Issue
Block a user