2f90e1a97f
Findings fixed: the gate's buttons had no stable ids (fragile to test and maintain), and finishing the gate left the member on the pitch tab where the profile card and Messages are not visible, so completion now opens the Position Dashboard tab. /api/public/profile/email-start returns devCode outside production so the flow is testable locally, matching the InstantAdPay pattern. qa/profiles-unit.mjs (28 assertions) and qa/gate-e2e.mjs (47 assertions, real sessions, real UI) with a README. All pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
213 lines
11 KiB
JavaScript
213 lines
11 KiB
JavaScript
// RM Circle: required member profile gate (Marty, 2026-09-16).
|
|
//
|
|
// The member area only opens once a position has a username and a VERIFIED email.
|
|
// It fires the moment ownership is proved (wallet personal_sign, or the Telegram
|
|
// Mini App bridge) and cannot be dismissed, because the whole point is that a
|
|
// leader can reach every member. The public /my/<id> page is untouched: anyone
|
|
// can still read the chain data there, and nobody can write a profile from it.
|
|
//
|
|
// Usage: await window.RMCProfile.require(); // resolves once the profile is complete
|
|
(function () {
|
|
'use strict';
|
|
var back = null, resolveDone = null, state = null, editable = false;
|
|
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', 'Finish setting up your member profile');
|
|
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';
|
|
if (editable) { // editing an existing profile can be abandoned; the first-time gate can not
|
|
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 (editable) 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('This is how your team leader and the people in your line see you, instead of a bare member number. Letters, numbers or underscores, 3 to 20 characters.'));
|
|
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 (editable) { 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('Two reasons this is required. Your leader can actually reach you, and you get a note the moment a payout lands in your wallet. ' +
|
|
'It is never shown to other members, never sold, and you can change 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 (editable) { 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();
|
|
}
|
|
|
|
// Resolves once the profile is complete. Safe to call repeatedly: it returns
|
|
// immediately when there is nothing to collect, and never shows for a visitor
|
|
// who has not proved they own the position.
|
|
// opts.onlyForId: only gate when the page being viewed IS this member's own
|
|
// position (Marty, 2026-09-16), so browsing a teammate's dashboard never prompts.
|
|
async function require_(opts) {
|
|
try { state = await api('/api/public/profile'); }
|
|
catch (e) { return null; }
|
|
if (!state || state.signedIn === false) return null; // a visitor on a shared link: never gate
|
|
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; // already open
|
|
shell();
|
|
return new Promise(function (res) { resolveDone = res; next(); });
|
|
}
|
|
async function status() { try { return await api('/api/public/profile'); } catch (e) { return null; } }
|
|
// Change an existing username or email. Dismissable, unlike the first-time gate.
|
|
// 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;
|
|
editable = true;
|
|
shell();
|
|
var p = new Promise(function (res) { resolveDone = res; });
|
|
if (kind === 'email') stepEmail(); else stepUsername();
|
|
var out = await p; editable = false; return out;
|
|
}
|
|
|
|
window.RMCProfile = { require: require_, status: status, edit: edit };
|
|
})();
|