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:
+73
-5
@@ -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'); }
|
||||
|
||||
Reference in New Issue
Block a user