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