diff --git a/profiles.js b/profiles.js index 93242dd..5d125da 100644 --- a/profiles.js +++ b/profiles.js @@ -146,6 +146,33 @@ function verifyEmail(id, rawCode) { 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 ---- function contactFor(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 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 }; diff --git a/public/my.js b/public/my.js index 98c03d5..5271dfc 100644 --- a/public/my.js +++ b/public/my.js @@ -333,9 +333,10 @@ box.style.cssText='margin:0 0 18px;border-color:rgba(78,214,203,.4)'; box.innerHTML='

Get a note when you get paid

'+ '

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.

'+ + 'Never shown to other members, never sold.

'+ + OPT_BADGE+ ' '+ - ''; + ''; 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+=`
Messages reach you here only. Add an email and they reach you off the site too, plus a note whenever a payout lands. Optional, private, removable any time.
`; + banner+=`
`+ + `Messages reach you here only. Add an email and they reach you off the site too, plus a note whenever a payout lands. `+ + ``+ + OPT_BADGE.replace('margin:0 0 14px','margin:11px 0 0')+`
`; } 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='
'+ + '✳️'+ + '
100% OPTIONAL — never required
'+ + '
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.
'; 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=`
`+ + el.innerHTML=OPT_BADGE+ + `
`+ `
Username
@${esc(pr.profile.username)}
`+ `
Email ✓ confirmed
${esc(pr.profile.email)}
`+ `
`+ ``+ - `

Payout alerts go to this address. Changing it here changes both.

`; + // 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. + `
`+ + `
Changed your mind? Take any of it back off, any time.
`+ + ` `+ + ` `+ + ``+ + `
`+ + `

Payout alerts go to this address. Changing it here changes both, and removing it stops those emails.

`; 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){} diff --git a/public/profile-gate.js b/public/profile-gate.js index e9a1210..9ad33f3 100644 --- a/public/profile-gate.js +++ b/public/profile-gate.js @@ -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);', + '✳️
' + + '
100% OPTIONAL — never required
' + + '
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.
')); return card; } function note(text, color) { diff --git a/qa/gate-e2e.mjs b/qa/gate-e2e.mjs index 04db079..478047c 100644 --- a/qa/gate-e2e.mjs +++ b/qa/gate-e2e.mjs @@ -1,127 +1,162 @@ -// 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 -// 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 -// cannot be delivered without them, and even there it is an invitation. -// -// Run: LOCAL=... TOKEN= TOKEN2= node qa/gate-e2e.mjs -import { pathToFileURL } from 'node:url'; -const PW = 'D:/Projects/MarketingAgent/qa-tester/node_modules/playwright'; -const { chromium } = (await import(pathToFileURL(PW + '/index.js').href)).default; -const B = process.env.LOCAL || 'http://127.0.0.1:3399'; -const TOKEN = process.env.TOKEN; // position 21, no profile yet -const TOKEN2 = process.env.TOKEN2; // position 49, seeded email, no username -const ok = [], bad = []; -const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); }; -const browser = await chromium.launch(); -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 } }); - // 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 - // swallow clicks, so mark them already-seen instead of racing their fade-out. - await c.addInitScript(() => { - const g = Storage.prototype.getItem; - Storage.prototype.getItem = function (k) { - if (/^rmc-promo-/.test(k)) return 'seen'; - if (/^rmc-announce-/.test(k)) return 'done'; - return g.call(this, k); - }; - }); - if (tok) await c.addCookies([{ name: 'ctb.msid', value: tok, url: B }]); - return c; -}; -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 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 ---------- -const ctx = await ctxFor(TOKEN); -const p = await ctx.newPage(); -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))); -await openDash(p); -t('still no modal after opening the dashboard', !(await modalOpen(p))); -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('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('"Not now" is offered', await p.evaluate(() => !!document.getElementById('pcNo'))); - -// declining costs nothing and snoozes -await p.click('#pcNo'); await p.waitForTimeout(600); -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); -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('and still no modal', !(await modalOpen(p))); - -// ---------- 2. opting in works, and the dialog can be abandoned ---------- -await p.evaluate(() => { try { localStorage.removeItem('rmc.profileSnooze'); } catch (e) {} }); -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'))); -await p.click('#pcGo'); await p.waitForTimeout(1000); -t('the dialog opens on request', await modalOpen(p)); -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)); -await p.click('#pgClose'); await p.waitForTimeout(600); -t('abandoning the dialog is allowed', !(await modalOpen(p))); - -// complete it for real -await p.click('#pcGo'); await p.waitForTimeout(900); -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) {} } }); -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, -// 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('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('#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)); -await p.waitForTimeout(3200); -await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p); -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 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'))); - -// ---------- 3. the inbox asks only where it matters ---------- -const ctx2 = await ctxFor(TOKEN2); const p3 = await ctx2.newPage(); -await p3.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p3.waitForTimeout(2400); await openDash(p3); -t('no modal for the member without contact details', !(await modalOpen(p3))); -const body3 = await p3.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 inbox ask is phrased as optional', /Optional, private, removable/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); +// 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 +// 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 +// cannot be delivered without them, and even there it is an invitation. +// +// Run: LOCAL=... TOKEN= TOKEN2= node qa/gate-e2e.mjs +import { pathToFileURL } from 'node:url'; +const PW = 'D:/Projects/MarketingAgent/qa-tester/node_modules/playwright'; +const { chromium } = (await import(pathToFileURL(PW + '/index.js').href)).default; +const B = process.env.LOCAL || 'http://127.0.0.1:3399'; +const TOKEN = process.env.TOKEN; // position 21, no profile yet +const TOKEN2 = process.env.TOKEN2; // position 49, seeded email, no username +const ok = [], bad = []; +const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); }; +const browser = await chromium.launch(); +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 } }); + // 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 + // swallow clicks, so mark them already-seen instead of racing their fade-out. + await c.addInitScript(() => { + const g = Storage.prototype.getItem; + Storage.prototype.getItem = function (k) { + if (/^rmc-promo-/.test(k)) return 'seen'; + if (/^rmc-announce-/.test(k)) return 'done'; + return g.call(this, k); + }; + }); + if (tok) await c.addCookies([{ name: 'ctb.msid', value: tok, url: B }]); + return c; +}; +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 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 ---------- +const ctx = await ctxFor(TOKEN); +const p = await ctx.newPage(); +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))); +await openDash(p); +t('still no modal after opening the dashboard', !(await modalOpen(p))); +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('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('"Not now" is offered', await p.evaluate(() => !!document.getElementById('pcNo'))); + +// declining costs nothing and snoozes +await p.click('#pcNo'); await p.waitForTimeout(600); +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); +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('and still no modal', !(await modalOpen(p))); + +// ---------- 2. opting in works, and the dialog can be abandoned ---------- +await p.evaluate(() => { try { localStorage.removeItem('rmc.profileSnooze'); } catch (e) {} }); +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'))); +await p.click('#pcGo'); await p.waitForTimeout(1000); +t('the dialog opens on request', await modalOpen(p)); +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)); +await p.click('#pgClose'); await p.waitForTimeout(600); +t('abandoning the dialog is allowed', !(await modalOpen(p))); + +// complete it for real +await p.click('#pcGo'); await p.waitForTimeout(900); +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) {} } }); +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, +// 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('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('#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)); +await p.waitForTimeout(3200); +await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p); +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 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'))); + +// ---------- 2b. OPTIONAL is unmissable, and removal actually works ---------- +// Manson asked for a HUGE asterisk so nobody can say they did not see it, and Marty +// asked for a real opt-out. "Removable any time" was written in three places with no +// way to do it, which is the same class of failure as the dead "Add mine" button. +const body2b = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); +t('the profile card shouts that it is optional', /100% OPTIONAL/i.test(body2b), body2b.slice(0, 120)); +t('and says nothing is affected without it', /work exactly the same without it/i.test(body2b)); +t('Remove username is offered', await p.evaluate(() => !!document.getElementById('pgDropUser'))); +t('Remove email is offered', await p.evaluate(() => !!document.getElementById('pgDropMail'))); +t('Remove everything is offered', await p.evaluate(() => !!document.getElementById('pgDropAll'))); + +// one tap ARMS, it must not remove anything yet +await p.click('#pgDropMail'); await p.waitForTimeout(500); +t('one tap only arms, nothing removed yet', /Tap again to remove/i.test(await p.evaluate(() => document.getElementById('pgDropMail').textContent))); +t('and it explains the consequence before the second tap', + /changes nothing about your position/i.test(await p.evaluate(() => (document.getElementById('pgDropMsg') || {}).textContent || ''))); +const stillThere = await p.evaluate(() => document.body.innerText); +t('the email is still on file after one tap', /optin@example\.com/.test(stillThere)); + +// second tap removes it, for real, in the store +await p.click('#pgDropMail'); await p.waitForTimeout(2500); +const afterDrop = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); +t('second tap actually removes the email', !/optin@example\.com/.test(afterDrop), afterDrop.slice(0, 140)); +const st = await p.evaluate(async () => (await (await fetch('/api/public/profile')).json())); +t('the server agrees the email is gone', st && st.profile && !st.profile.email, JSON.stringify(st && st.profile)); +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); + +// and it is reversible: the invitation comes back +await p.evaluate(() => { try { localStorage.removeItem('rmc.profileSnooze'); } catch (e) {} }); +await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p); +t('after opting out the invitation returns, so it is reversible', await p.evaluate(() => !!document.getElementById('pcGo'))); + +// ---------- 3. the inbox asks only where it matters ---------- +const ctx2 = await ctxFor(TOKEN2); const p3 = await ctx2.newPage(); +await p3.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p3.waitForTimeout(2400); await openDash(p3); +t('no modal for the member without contact details', !(await modalOpen(p3))); +const body3 = await p3.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)); +// 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); diff --git a/server.js b/server.js index 77b38a4..cf37916 100644 --- a/server.js +++ b/server.js @@ -1486,9 +1486,34 @@ async function handleApi(req,res,pathname){ if(!s)return json(res,401,{error:'Not signed in.'}); const b=await bodyJson(req).catch(()=>null); 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); 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(!requireAdmin(req,res))return; return json(res,200,{coverage:profiles.coverage(),profiles:profiles.adminList()});