Linked positions (Qualified Start): extra wallets on one account, credits pooled, buy-from picker

- accounts/db: positions table + JSON store (add/list/owner/remove; main-wallet and cross-account guards)
- auth verify: asPosition links a second wallet without touching the session; position wallets can't mint a session
- /api/my/positions (chain-refreshed member ids, buyer counts, credits) + remove
- credits pooled across main + positions on /api/me, dashboard, campaigns; campaign charged to the best-funded position
- My line: own positions listed on level 1 as 'You · position N'
- Buy pane: Qualified Start card + Add a position flow + Buy-from picker with connected-wallet guard
- Wallet pane: Your positions card; chatbot answer updated

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-09 17:18:47 -05:00
parent b86dbfd9e9
commit 046464b83e
9 changed files with 327 additions and 14 deletions
+73 -5
View File
@@ -500,6 +500,7 @@
$('posLine').innerHTML = who.join('<br>');
$('creditLine').textContent = (me.credits || 0).toLocaleString();
loadPositions(me);
$('linkCard').hidden = !!me.address;
$('activateCard').hidden = !(me.address && !me.memberId);
$('activityArea').hidden = !me.memberId;
@@ -956,7 +957,7 @@
}
el.innerHTML = r.levels.map(L => !L.members.length ? '' :
'<div class="lin-lvl"><div class="cap">Level ' + L.level + ' · ' + L.members.length + (L.level === 1 ? ' direct' : '') + '</div>'
+ L.members.map(m => '<div class="lin-row"><span class="nm">' + esc(m.name) + '</span>'
+ L.members.map(m => '<div class="lin-row' + (m.own ? ' own' : '') + '"><span class="nm">' + esc(m.name) + (m.own ? ' <span class="badge">yours</span>' : '') + '</span>'
+ (m.email ? '<span class="em">' + esc(m.email) + '</span>' : '<span class="id">#' + m.memberId + '</span>')
+ '<span class="earn' + (m.earnedWei && m.earnedWei !== '0' ? ' on' : '') + '" title="POL this person has paid you so far">'
+ (m.earnedWei && m.earnedWei !== '0' ? '+' + IAP.fmtPol(m.earnedWei) + ' POL' : '0.00 POL') + '</span>'
@@ -1205,6 +1206,57 @@
}
} catch (e) { IAP.status('Could not open MoonPay: ' + ((e && e.message) || e), 'bad'); }
}
// ── linked positions (Qualified Start) ──
const short = a => a ? a.slice(0, 6) + '…' + a.slice(-4) : '';
async function loadPositions(me) {
try {
const r = await (await fetch('/api/my/positions')).json();
if (r.error) return;
const list = r.positions || [];
const rows = list.map((p, i) => '<div class="lin-row"><span class="nm">Position ' + (i + 2) + ' <span class="mono">' + short(p.address) + '</span></span>'
+ '<span class="id">' + (p.memberId ? '#' + p.memberId : 'not on-chain yet') + '</span>'
+ '<span class="earn' + (p.counted ? ' on' : '') + '">' + (p.counted ? 'counts as a qualifying buyer' : p.memberId ? 'registered, buy $20+ to count' : 'buy a $20+ package to register it') + '</span>'
+ '<span class="dt">' + (p.credits || 0).toLocaleString() + ' credits</span>'
+ (!p.memberId ? '<button class="btn sec small" type="button" data-unlink="' + p.address + '">Unlink</button>' : '')
+ '</div>').join('');
const mainRow = r.main && r.main.address ? '<div class="lin-row"><span class="nm">Position 1 · main <span class="mono">' + short(r.main.address) + '</span></span>'
+ '<span class="id">' + (r.main.memberId ? '#' + r.main.memberId : 'payouts not on yet') + '</span>'
+ '<span class="earn on">' + (r.main.buyerCount || 0) + ' qualifying buyer(s)</span>'
+ '<span class="dt">' + (r.main.credits || 0).toLocaleString() + ' credits</span></div>' : '';
const html = mainRow + rows + (list.length ? '<p class="muted small" style="margin:8px 0 0">Pooled credits: <b>' + (r.totalCredits || 0).toLocaleString() + '</b>. A campaign budget spends from one position at a time.</p>' : '');
if ($('qsList')) $('qsList').innerHTML = html;
if ($('posList')) $('posList').innerHTML = html;
if ($('posCard')) $('posCard').hidden = !list.length;
// "Buy from" picker: main + every position
const sel = $('buyFrom');
if (sel) {
const keep = sel.value;
sel.innerHTML = '<option value="main">Main wallet ' + (r.main && r.main.address ? short(r.main.address) : '(link it first)') + (r.main && r.main.memberId ? ' · #' + r.main.memberId : '') + '</option>'
+ list.map((p, i) => '<option value="' + p.address + '">Position ' + (i + 2) + ' ' + short(p.address) + (p.memberId ? ' · #' + p.memberId : ' · not registered yet') + (p.counted ? ' · counted' : '') + '</option>').join('');
if (keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
$('buyFromWrap').hidden = !list.length;
}
document.querySelectorAll('[data-unlink]').forEach(b => b.addEventListener('click', async () => {
if (!confirm('Unlink ' + short(b.dataset.unlink) + ' from your account?')) return;
try { await api('/api/my/positions/remove', { address: b.dataset.unlink }); IAP.status('Position unlinked.', 'ok'); loadPositions(); }
catch (e) { IAP.status(e.message, 'bad'); }
}));
} catch (e) {}
}
if ($('qsAddBtn')) $('qsAddBtn').addEventListener('click', busy2($('qsAddBtn'), async () => {
const me = await (await fetch('/api/me')).json();
if (!me.address) throw new Error('Link your main wallet first (Wallet tab), then add positions under it.');
if (!me.memberId) throw new Error('Switch on payouts for your main wallet first (Wallet tab). Positions register under your member number.');
if (!confirm('In your wallet app, switch to a DIFFERENT account than ' + short(me.address) + ' first (MetaMask: account menu, Add account. Trust or SafePal: switch wallet). The wallet picker will open again so you can connect that account.\n\nReady?')) return;
await IAPWallet.disconnect();
IAP.status('Connect the new account in the picker, then sign once…');
const r = await IAPWallet.signIn({ asPosition: true });
$('qsHint').textContent = 'Added ' + short(r.address) + '. Now choose it under "Buy from" and buy a $20 or larger package.';
IAP.status('Position added: ' + short(r.address) + '. Pick it under "Buy from" above and buy a $20+ package to count it.', 'ok');
await loadPositions();
const sel = $('buyFrom'); if (sel) sel.value = r.address.toLowerCase();
try { $('buyFrom').scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) {}
}));
async function loadBuyTiles() {
try {
const { products } = await (await fetch('/api/catalog')).json();
@@ -1233,17 +1285,33 @@
// fetches when the page returns from the wallet app-switch
const jretry = async url => { let err; for (let i = 0; i < 4; i++) { try { return await (await fetch(url)).json(); } catch (e) { err = e; await new Promise(s => setTimeout(s, 500 * (i + 1))); } } throw err; };
const meNow = await jretry('/api/me');
if (!meNow.address) {
// which of the member's wallets is buying: the main wallet (default) or a
// linked position (Qualified Start). A position registers under the main
// member id on its first buy, so its sponsor is always this member.
const fromSel = $('buyFrom');
const fromPos = (fromSel && !$('buyFromWrap').hidden && fromSel.value && fromSel.value !== 'main') ? fromSel.value.toLowerCase() : null;
if (fromPos && !meNow.memberId) { IAP.status('Switch on payouts for your main wallet first (Wallet tab), so this position can register under you.', 'bad'); return; }
if (!fromPos && !meNow.address) {
IAP.status('Link your wallet first — one quick signature…');
await IAPWallet.signIn();
}
const spNow = await jretry('/api/sponsor');
const wantAddr = fromPos || (meNow.address ? meNow.address.toLowerCase() : null);
if (wantAddr) { // never buy from a wallet other than the one selected: a stray wallet would register a brand-new member
let cur = (IAPWallet.address() || await IAPWallet.connect() || '').toLowerCase();
if (cur !== wantAddr) {
IAP.status('Switch your wallet app to ' + wantAddr.slice(0, 6) + '…' + wantAddr.slice(-4) + ', then pick it in the picker…');
await IAPWallet.disconnect();
cur = String(await IAPWallet.connect() || '').toLowerCase();
}
if (cur !== wantAddr) throw new Error('Your wallet connected as ' + cur.slice(0, 6) + '…' + cur.slice(-4) + ' but you chose ' + wantAddr.slice(0, 6) + '…' + wantAddr.slice(-4) + '. Switch accounts in your wallet app and try again.');
}
const spNow = fromPos ? { sponsorId: meNow.memberId } : await jretry('/api/sponsor');
// pre-flight: stop early if the POL is not there. Trust Wallet also hard-blocks any
// transaction that spends most of the balance ("drain your wallet"), so Trust users
// get a heads-up first; other wallets go straight to the confirmation.
try {
const need = BigInt(b.dataset.cost) + BigInt(b.dataset.cost) / 50n; // same 2% pad as buy()
const bal = await IAPWallet.balance(IAPWallet.address() || meNow.address);
const bal = await IAPWallet.balance(wantAddr || IAPWallet.address() || meNow.address);
if (bal < need) {
IAP.status('That wallet holds ' + IAP.fmtPol(bal.toString()) + ' POL, but this package needs about ' + IAP.fmtPol(need.toString()) + ' POL plus a little for gas. Top it up and try again.', 'bad');
return;
@@ -1258,7 +1326,7 @@
IAP.status('Confirm the purchase in your wallet…');
const r = await IAPWallet.buy(Number(b.dataset.id), spNow.sponsorId || 0, b.dataset.cost);
if (r.status !== '0x1' && r.receipt && r.receipt.status !== '0x1') throw new Error('Transaction reverted.');
IAP.status('Purchase settled on-chain. Credits are in your account.', 'ok');
IAP.status(fromPos ? 'Purchase settled on-chain. That position now counts toward your qualification, and its credits pool with yours.' : 'Purchase settled on-chain. Credits are in your account.', 'ok');
await render();
loadBuyTiles();
} catch (e) { IAP.status('Purchase failed: ' + ((e && e.message) || e), 'bad'); }
+2 -2
View File
@@ -148,7 +148,7 @@ window.IAPWallet = (function () {
}
// SIWE: challenge -> personal_sign -> verify (server sets the session cookie)
async function signIn() {
async function signIn(opts) {
const addr = await connect();
const ch = await (await fetch('/api/auth/challenge', { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr }) })).json();
@@ -157,7 +157,7 @@ window.IAPWallet = (function () {
const hexMsg = '0x' + Array.from(new TextEncoder().encode(ch.message)).map(b => b.toString(16).padStart(2, '0')).join('');
const sig = await eth().request({ method: 'personal_sign', params: [hexMsg, addr] });
const r = await (await fetch('/api/auth/verify', { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr, signature: sig }) })).json();
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr, signature: sig, asPosition: !!(opts && opts.asPosition) }) })).json();
if (r.error) throw new Error(r.error);
return r;
}
+25
View File
@@ -324,8 +324,27 @@
to this account instantly, and every payout in your sponsor line lands the moment you confirm.</p>
<p class="small" style="border:1px solid var(--line-strong);border-radius:10px;padding:10px 12px;margin:0 0 14px">
<b>Using Trust Wallet?</b> It blocks purchases that spend most of the POL in the wallet (a "drain your wallet" warning). Start with a smaller package, keep a little extra POL, or connect MetaMask, Phantom or SafePal instead. Extra POL always stays yours.</p>
<div id="buyFromWrap" class="small" hidden style="border:1px solid var(--line-strong);border-radius:10px;padding:10px 12px;margin:0 0 14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<b>Buy from</b>
<select id="buyFrom" class="input" style="max-width:340px"></select>
<span class="muted" id="buyFromHint">Your wallet app must be on this account when you confirm.</span>
</div>
<div class="tiles" id="boTiles"><div class="tile"><span class="muted small">Loading live prices…</span></div></div>
</div>
<div class="card" id="qsCard">
<h3>Qualified Start: your own positions</h3>
<p class="muted small">Qualification is earned by buyers you refer, and it is never for sale. But nothing stops you from being your own first buyers, openly. Link a second wallet of your own as a <b>position</b> under your account. When it buys a $20 or larger package, the contract counts it as a qualifying buyer, half the purchase comes straight back to your main wallet, and the credits it mints pool with yours. Two positions open level 2 the same day. Five open level 3.</p>
<ol class="small" style="margin:0 0 12px 18px;padding:0;line-height:1.6">
<li>Link your main wallet and switch on payouts (Wallet tab) so positions can register under you.</li>
<li>In your wallet app, create or switch to a <b>different account</b> (MetaMask: account menu, Add account. Trust or SafePal: switch wallet). Put enough POL in it for a $20 package plus gas.</li>
<li>Click <b>Add a position</b>, pick that account in the picker, and sign once.</li>
<li>Choose it under <b>Buy from</b> above and buy a $20 or larger package.</li>
</ol>
<p><button class="btn" id="qsAddBtn" type="button">Add a position</button>
<span class="small muted" id="qsHint"></span></p>
<div id="qsList" class="small"></div>
<p class="muted small" style="margin:12px 0 0">Your own money, your own wallets, a faster start. It is not an income promise: qualification only pays on future purchases in your line, and every purchase here is a real ad package you can spend.</p>
</div>
</div>
<div class="pane" id="pane-campaigns" hidden>
@@ -718,6 +737,12 @@
<p><button class="btn sec small" id="wcDisconnect" type="button">Disconnect wallet</button>
<span class="small muted" id="wcDisconnectInfo"></span></p>
</div>
<div class="card" id="posCard" hidden>
<h3>Your positions</h3>
<p class="muted small">Extra wallets you own, registered under your main member. Their credits pool with yours and each one that buys $20 or more counts toward your qualification.</p>
<div id="posList" class="small"></div>
<p style="margin:10px 0 0"><a class="btn sec small" href="#buy">Add or buy from a position</a></p>
</div>
<div class="grid c2">
<div class="card"><h3>Your account</h3>
<p id="posLine" class="muted small">…</p></div>