91a6893df9
Manson's objection was that requiring a username and a verified email pulls the
build back toward a centralized database of members. He is right, and the
communication gap is real too, so the answer is to ask well rather than to force.
Nothing about holding a position, getting paid, reading the org, the training or
the tools depends on contact details any more. There is no onboarding gate: a
brand-new member registers, lands on their page and is never stopped by a modal.
The dashboard offers a dismissable card ("Not now" snoozes it for a week) that
leads with the thing members actually want, a note the moment a payout lands in
their wallet, and says outright that everything works the same without it. The
inbox is the one place that asks, because a message cannot be delivered to
someone who left no way to reach them, and even there it is an invitation.
The card sits above the tab strip rather than inside the dashboard pane: the page
opens on the pitch tab, so an invitation parked in the dashboard would never be
seen by the new members it is aimed at.
For leaders, /api/public/reach answers "how many of my org can I reach off the
site", scoped by chain.isInTeam so it leaks nothing upward or sideways. That
makes coverage a leader's own problem to solve by asking, not a rule imposed on
members.
Fixes a real bug found by the rewritten suite: the dismissable flag double-booked
as "single-field edit", so saving a username in the opt-in flow closed the dialog
instead of advancing to the email step. Split into oneShot; the suite now asserts
the advance as a regression.
QA, all green: profiles-unit 28, signin-fallback 7, gate-e2e 33 (rewritten to
assert the opposite of what it used to: no forced modal, dismissable everywhere,
visitors unaffected), join-flow 12 cold / 11 refuse / 12 warm.
qa/reseed.sh carries two hard-won guards: never name a shell variable TMP on
Windows (it inherits the system temp dir and rm -rf wipes it), and never pkill.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
220 lines
12 KiB
JavaScript
220 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'));
|
|
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 };
|
|
})();
|