2f4b70c886
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>
229 lines
12 KiB
JavaScript
229 lines
12 KiB
JavaScript
// RM Circle: OPTIONAL member profile (Marty + Manson, 2026-09-17).
|
|
//
|
|
// Contact details are never required to hold a position, get paid, read the org,
|
|
// use the training or the tools. Nothing on chain depends on them. They are asked
|
|
// for in exactly one place where their absence is the whole problem: a message
|
|
// cannot be delivered to someone who has given no way to reach them. Everything
|
|
// else is an invitation the member can decline, and declining costs them nothing.
|
|
//
|
|
// Usage: window.RMCProfile.prompt({ reason: 'alerts'|'inbox' }) // dismissable
|
|
// window.RMCProfile.edit('username'|'email') // change later
|
|
// window.RMCProfile.status() // read-only
|
|
(function () {
|
|
'use strict';
|
|
var back = null, resolveDone = null, state = null, oneShot = false, showSteps = true, reason = 'alerts';
|
|
var GOLD = '#d4af37', TEAL = '#4ed6cb';
|
|
|
|
function el(tag, css, html) {
|
|
var e = document.createElement(tag);
|
|
if (css) e.style.cssText = css;
|
|
if (html != null) e.innerHTML = html;
|
|
return e;
|
|
}
|
|
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]; }); }
|
|
async function api(path, body) {
|
|
var r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {});
|
|
var j = null; try { j = await r.json(); } catch (e) { j = {}; }
|
|
if (!r.ok || j.error) throw new Error(j.error || 'Something went wrong. Try again.');
|
|
return j;
|
|
}
|
|
|
|
function shell() {
|
|
back = el('div', 'position:fixed;inset:0;z-index:2147483100;display:flex;align-items:center;justify-content:center;' +
|
|
'padding:20px;background:rgba(3,7,14,.88);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);overflow:auto;');
|
|
back.setAttribute('role', 'dialog');
|
|
back.setAttribute('aria-label', 'Member profile — optional');
|
|
var card = el('div', 'position:relative;width:100%;max-width:440px;max-height:94vh;overflow:auto;border-radius:20px;' +
|
|
'background:#0a1119;border:1px solid rgba(212,175,55,.55);box-shadow:0 24px 70px rgba(0,0,0,.7);' +
|
|
'font-family:system-ui,Segoe UI,Arial,sans-serif;color:#e8eef6;padding:22px 22px 20px;');
|
|
card.id = 'pgCard';
|
|
{ // every profile dialog can be closed: nothing here is compulsory
|
|
var x = el('button', 'position:absolute;top:10px;right:12px;z-index:2;width:34px;height:34px;border-radius:50%;border:none;' +
|
|
'cursor:pointer;background:rgba(255,255,255,.08);color:#fff;font-size:20px;line-height:1;font-weight:700;', '×');
|
|
x.id = 'pgClose';
|
|
x.setAttribute('aria-label', 'Close');
|
|
x.addEventListener('click', close);
|
|
card.appendChild(x);
|
|
back.addEventListener('click', function (e) { if (e.target === back) close(); });
|
|
}
|
|
back.appendChild(card);
|
|
document.body.appendChild(back);
|
|
return card;
|
|
}
|
|
|
|
function head(card, step) {
|
|
var keep = card.querySelector('button[aria-label="Close"]');
|
|
card.innerHTML = '';
|
|
if (keep) card.appendChild(keep);
|
|
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) {
|
|
return el('p', 'margin:0 0 14px;font-size:13.5px;line-height:1.6;color:' + (color || '#9fb3c8') + ';', text);
|
|
}
|
|
function input(id, placeholder, value, type) {
|
|
var i = el('input');
|
|
i.id = id; i.type = type || 'text'; i.placeholder = placeholder; i.value = value || '';
|
|
i.autocomplete = type === 'email' ? 'email' : 'off';
|
|
i.style.cssText = 'width:100%;box-sizing:border-box;padding:12px 14px;border-radius:11px;border:1px solid rgba(255,255,255,.16);' +
|
|
'background:rgba(255,255,255,.04);color:#fff;font-size:16px;margin-bottom:10px;';
|
|
return i;
|
|
}
|
|
function button(label, id) {
|
|
var b = el('button', 'width:100%;padding:13px 16px;border-radius:11px;border:none;cursor:pointer;font-size:15px;font-weight:700;' +
|
|
'background:linear-gradient(180deg,' + GOLD + ',#b8932f);color:#1a1205;', label);
|
|
if (id) b.id = id;
|
|
return b;
|
|
}
|
|
function errLine() { return el('p', 'margin:0 0 10px;font-size:13px;color:#ff8b8b;display:none;'); }
|
|
|
|
// ---- step 1: username ----
|
|
function stepUsername() {
|
|
var card = head(document.getElementById('pgCard'), 1);
|
|
card.appendChild(el('h2', 'margin:0 0 6px;font-size:21px;line-height:1.25;color:#fff;', 'Pick your username'));
|
|
card.appendChild(note('How your team sees you instead of a bare member number. Letters, numbers or underscores, 3 to 20 characters. Optional, and changeable any time.'));
|
|
var err = errLine(); card.appendChild(err);
|
|
var i = input('pgUser', 'e.g. ' + (state.suggest || 'member' + state.id), (state.profile && state.profile.username) || '');
|
|
card.appendChild(i);
|
|
var b = button('Save and continue', 'pgUserSave');
|
|
card.appendChild(b);
|
|
var go = async function () {
|
|
err.style.display = 'none'; b.disabled = true; b.textContent = 'Saving…';
|
|
try {
|
|
var r = await api('/api/public/profile/username', { username: i.value });
|
|
state.profile = r.profile;
|
|
if (oneShot) { close(); return; }
|
|
next();
|
|
} catch (e) {
|
|
err.textContent = e.message; err.style.display = 'block'; b.disabled = false; b.textContent = 'Save and continue';
|
|
}
|
|
};
|
|
b.addEventListener('click', go);
|
|
i.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); go(); } });
|
|
setTimeout(function () { i.focus(); }, 60);
|
|
}
|
|
|
|
// ---- step 2: email + code ----
|
|
function stepEmail() {
|
|
var card = head(document.getElementById('pgCard'), 2);
|
|
var prefill = (state.profile && state.profile.email) || '';
|
|
card.appendChild(el('h2', 'margin:0 0 6px;font-size:21px;line-height:1.25;color:#fff;', 'Confirm your email'));
|
|
card.appendChild(note((reason === 'inbox'
|
|
? 'Your inbox needs somewhere to reach you. Add an email and your leader\'s messages reach you even when you are not on the site. '
|
|
: 'Get an email the moment a payout lands in your wallet, and your leader can reach you when it matters. ') +
|
|
'Optional, never shown to other members, never sold, and you can remove it any time.' +
|
|
(prefill ? ' We already have this one on file for your payout alerts, so just confirm it.' : '')));
|
|
var err = errLine(); card.appendChild(err);
|
|
var i = input('pgEmail', 'you@example.com', prefill, 'email');
|
|
card.appendChild(i);
|
|
var b = button(prefill ? 'Send me the code' : 'Send me a code', 'pgMailSend');
|
|
card.appendChild(b);
|
|
var codeWrap = el('div', 'display:none;margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,.1);');
|
|
var sentNote = note('', TEAL); codeWrap.appendChild(sentNote);
|
|
var ci = input('pgCode', '6-digit code', '');
|
|
ci.inputMode = 'numeric'; ci.maxLength = 6;
|
|
codeWrap.appendChild(ci);
|
|
var cb = button('Confirm and finish', 'pgCodeConfirm');
|
|
codeWrap.appendChild(cb);
|
|
var again = el('p', 'margin:10px 0 0;font-size:12.5px;color:#7f93a8;text-align:center;cursor:pointer;', 'Wrong address? Change it and send a new code.');
|
|
again.addEventListener('click', function () { codeWrap.style.display = 'none'; b.disabled = false; b.textContent = 'Send me a code'; i.focus(); });
|
|
codeWrap.appendChild(again);
|
|
card.appendChild(codeWrap);
|
|
|
|
b.addEventListener('click', async function () {
|
|
err.style.display = 'none'; b.disabled = true; b.textContent = 'Sending…';
|
|
try {
|
|
var r = await api('/api/public/profile/email-start', { email: i.value });
|
|
sentNote.textContent = 'Code sent to ' + (r.to || i.value) + '. It lasts 15 minutes. Check spam the first time.';
|
|
codeWrap.style.display = 'block'; b.textContent = 'Code sent';
|
|
setTimeout(function () { ci.focus(); }, 60);
|
|
} catch (e) {
|
|
err.textContent = e.message; err.style.display = 'block'; b.disabled = false; b.textContent = 'Send me a code';
|
|
}
|
|
});
|
|
var confirm = async function () {
|
|
err.style.display = 'none'; cb.disabled = true; cb.textContent = 'Checking…';
|
|
try {
|
|
var r = await api('/api/public/profile/email-verify', { code: ci.value });
|
|
state.profile = r.profile;
|
|
if (oneShot) { close(); return; }
|
|
done();
|
|
} catch (e) {
|
|
err.textContent = e.message; err.style.display = 'block'; cb.disabled = false; cb.textContent = 'Confirm and finish';
|
|
}
|
|
};
|
|
cb.addEventListener('click', confirm);
|
|
ci.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); confirm(); } });
|
|
setTimeout(function () { i.focus(); }, 60);
|
|
}
|
|
|
|
function done() {
|
|
var card = head(document.getElementById('pgCard'), 2);
|
|
card.innerHTML = '<div style="text-align:center;padding:14px 0 6px">' +
|
|
'<div style="font-size:42px;line-height:1">✅</div>' +
|
|
'<h2 style="margin:10px 0 6px;font-size:21px;color:#fff">You are all set, @' + esc(state.profile.username) + '</h2>' +
|
|
'<p style="margin:0 0 16px;font-size:13.5px;line-height:1.6;color:#9fb3c8">Your leader can reach you now, and every payout to your wallet sends you a note.</p></div>';
|
|
var b = button('Open my dashboard', 'pgDone');
|
|
b.addEventListener('click', function () { close(true); });
|
|
card.appendChild(b);
|
|
setTimeout(function () { close(true); }, 2600);
|
|
}
|
|
function close(openDash) {
|
|
if (back && back.parentNode) back.parentNode.removeChild(back);
|
|
back = null;
|
|
if (openDash) { try { var b = document.querySelector('.mp-tab[data-tab="dash"]'); if (b) b.click(); } catch (e) {} }
|
|
if (resolveDone) { var r = resolveDone; resolveDone = null; r((state && state.profile) || null); }
|
|
}
|
|
function next() {
|
|
if (!state.profile || !state.profile.username) return stepUsername();
|
|
if (!state.profile.emailVerified) return stepEmail();
|
|
return done();
|
|
}
|
|
|
|
// An invitation, not a gate: fully dismissable, and declining costs nothing.
|
|
// opts.onlyForId - only for the member's own position, never a teammate's page.
|
|
// opts.reason - 'alerts' (default) or 'inbox', which picks the copy.
|
|
async function prompt_(opts) {
|
|
try { state = await api('/api/public/profile'); }
|
|
catch (e) { return null; }
|
|
if (!state || state.signedIn === false) return null;
|
|
var only = opts && opts.onlyForId;
|
|
if (only && Number(only) !== Number(state.id)) return null;
|
|
if (state.profile && state.profile.complete) return state.profile;
|
|
if (back) return null;
|
|
reason = (opts && opts.reason) || 'alerts';
|
|
oneShot = false; // full two-step opt-in, not a single-field edit
|
|
showSteps = true;
|
|
shell();
|
|
var p = new Promise(function (res) { resolveDone = res; });
|
|
next();
|
|
var out = await p; oneShot = false; return out;
|
|
}
|
|
async function status() { try { return await api('/api/public/profile'); } catch (e) { return null; } }
|
|
// Change one field. Opens straight at that step and closes on save.
|
|
// Resolves with the profile when saved, or null if the member closed it.
|
|
async function edit(kind) {
|
|
if (back) return null;
|
|
try { state = await api('/api/public/profile'); } catch (e) { return null; }
|
|
if (!state || state.signedIn === false) return null;
|
|
oneShot = true; showSteps = false;
|
|
shell();
|
|
var p = new Promise(function (res) { resolveDone = res; });
|
|
if (kind === 'email') stepEmail(); else stepUsername();
|
|
var out = await p; oneShot = false; return out;
|
|
}
|
|
|
|
window.RMCProfile = { prompt: prompt_, status: status, edit: edit };
|
|
})();
|