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:
+63
-2
@@ -49,6 +49,7 @@ const J = {
|
||||
try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) {}
|
||||
if (!this.db || !this.db.v) this.db = { v: 2, byEmail: {}, byAddress: {}, byCode: {}, joins: 0 };
|
||||
if (!this.db.byCode) this.db.byCode = {};
|
||||
if (!this.db.positions) this.db.positions = {};
|
||||
for (const a of Object.values(this.db.byEmail)) {
|
||||
if (!a.code) { a.code = newCode(c => this.db.byCode[c]); this.db.byCode[a.code] = a.email; }
|
||||
else if (!this.db.byCode[a.code]) this.db.byCode[a.code] = a.email;
|
||||
@@ -160,6 +161,7 @@ const J = {
|
||||
if (acct.address && acct.address !== a) return { error: 'This account is already linked to wallet '
|
||||
+ acct.address.slice(0, 6) + '…' + acct.address.slice(-4) + '. Connect that wallet instead.' };
|
||||
if (this.db.byAddress[a] && this.db.byAddress[a] !== e) return { error: 'That wallet is already linked to a different account.' };
|
||||
if (this.db.positions[a]) return { error: 'That wallet is already a linked position' + (this.db.positions[a].email === e ? ' on this account.' : ' on a different account.') };
|
||||
acct.address = a;
|
||||
this.db.byAddress[a] = e;
|
||||
this.save();
|
||||
@@ -172,7 +174,32 @@ const J = {
|
||||
acct.sponsorRef = ref; this.save();
|
||||
return { ok: true, account: pub(acct) };
|
||||
},
|
||||
async count() { return Object.keys(this.db.byEmail).length; }
|
||||
async count() { return Object.keys(this.db.byEmail).length; },
|
||||
// ---- linked positions (extra wallets on one account) ----
|
||||
async positions(e) {
|
||||
return Object.entries(this.db.positions).filter(([, p]) => p.email === e)
|
||||
.map(([address, p]) => ({ address, email: p.email, memberId: p.memberId || 0, created: p.created }))
|
||||
.sort((x, y) => x.created - y.created);
|
||||
},
|
||||
async addPosition(e, a) {
|
||||
const acct = this.db.byEmail[e];
|
||||
if (!acct) return { error: 'No such account.' };
|
||||
if (!acct.address) return { error: 'Link your main wallet first.' };
|
||||
if (acct.address === a) return { error: 'That is your main wallet. Switch to a different account in your wallet app, then try again.' };
|
||||
if (this.db.byAddress[a]) return { error: 'That wallet is already the main wallet of another account.' };
|
||||
const cur = this.db.positions[a];
|
||||
if (cur && cur.email !== e) return { error: 'That wallet is already a position on a different account.' };
|
||||
if (!cur) { this.db.positions[a] = { email: e, memberId: 0, created: Date.now() }; this.save(); }
|
||||
return { ok: true, address: a, created: !cur };
|
||||
},
|
||||
async setPositionMember(a, id) { const p = this.db.positions[a]; if (p && p.memberId !== id) { p.memberId = id; this.save(); } },
|
||||
async positionOwner(a) { const p = this.db.positions[a]; return p ? { address: a, email: p.email, memberId: p.memberId || 0 } : null; },
|
||||
async removePosition(e, a) {
|
||||
const p = this.db.positions[a];
|
||||
if (!p || p.email !== e) return { error: 'No such position.' };
|
||||
if (p.memberId) return { error: 'That position is already registered on-chain and cannot be unlinked.' };
|
||||
delete this.db.positions[a]; this.save(); return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
// ---- MySQL mode ----
|
||||
@@ -278,6 +305,8 @@ const D = {
|
||||
if (!cur) return { error: 'No such account.' };
|
||||
if (cur.address && cur.address !== a) return { error: 'This account is already linked to wallet '
|
||||
+ cur.address.slice(0, 6) + '…' + cur.address.slice(-4) + '. Connect that wallet instead.' };
|
||||
const pos = (await db.q('SELECT email FROM positions WHERE address=?', [a]))[0];
|
||||
if (pos) return { error: 'That wallet is already a linked position' + (pos.email === e ? ' on this account.' : ' on a different account.') };
|
||||
try { await db.q('UPDATE accounts SET address=? WHERE email=?', [a, e]); }
|
||||
catch (err) {
|
||||
if (err.code === 'ER_DUP_ENTRY') return { error: 'That wallet is already linked to a different account.' };
|
||||
@@ -291,7 +320,34 @@ const D = {
|
||||
if (!r.affectedRows) return { error: 'No such account.' };
|
||||
return { ok: true, account: await this.byEmail(e) };
|
||||
},
|
||||
async count() { const r = await db.q('SELECT COUNT(*) n FROM accounts'); return Number(r[0].n); }
|
||||
async count() { const r = await db.q('SELECT COUNT(*) n FROM accounts'); return Number(r[0].n); },
|
||||
// ---- linked positions (extra wallets on one account) ----
|
||||
async positions(e) {
|
||||
const rows = await db.q('SELECT * FROM positions WHERE email=? ORDER BY created', [e]);
|
||||
return rows.map(r => ({ address: r.address, email: r.email, memberId: r.member_id || 0, created: Number(r.created) }));
|
||||
},
|
||||
async addPosition(e, a) {
|
||||
const acct = await this.byEmail(e);
|
||||
if (!acct) return { error: 'No such account.' };
|
||||
if (!acct.address) return { error: 'Link your main wallet first.' };
|
||||
if (acct.address === a) return { error: 'That is your main wallet. Switch to a different account in your wallet app, then try again.' };
|
||||
if (await this.byAddress(a)) return { error: 'That wallet is already the main wallet of another account.' };
|
||||
const cur = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0];
|
||||
if (cur && cur.email !== e) return { error: 'That wallet is already a position on a different account.' };
|
||||
if (!cur) await db.q('INSERT INTO positions (address,email,member_id,created) VALUES (?,?,0,?)', [a, e, Date.now()]);
|
||||
return { ok: true, address: a, created: !cur };
|
||||
},
|
||||
async setPositionMember(a, id) { await db.q('UPDATE positions SET member_id=? WHERE address=? AND member_id<>?', [id, a, id]); },
|
||||
async positionOwner(a) {
|
||||
const r = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0];
|
||||
return r ? { address: r.address, email: r.email, memberId: r.member_id || 0 } : null;
|
||||
},
|
||||
async removePosition(e, a) {
|
||||
const r = (await db.q('SELECT * FROM positions WHERE address=? AND email=?', [a, e]))[0];
|
||||
if (!r) return { error: 'No such position.' };
|
||||
if (r.member_id) return { error: 'That position is already registered on-chain and cannot be unlinked.' };
|
||||
await db.q('DELETE FROM positions WHERE address=?', [a]); return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
const impl = () => db.enabled() ? D : J;
|
||||
@@ -392,4 +448,9 @@ module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUs
|
||||
getMutes: e => impl().getMutes(String(e || '').toLowerCase()),
|
||||
setMute: (o, t, m) => impl().setMute(String(o || '').toLowerCase(), String(t || '').toLowerCase(), m),
|
||||
sponsorOf, isDownlineOf, getChatSettings,
|
||||
positions: e => impl().positions(normEmail(e)),
|
||||
addPosition: (e, a) => { const x = normAddr(a); return /^0x[0-9a-f]{40}$/.test(x) ? impl().addPosition(normEmail(e), x) : Promise.resolve({ error: 'Bad wallet address.' }); },
|
||||
setPositionMember: (a, id) => impl().setPositionMember(normAddr(a), Number(id) || 0),
|
||||
positionOwner: a => impl().positionOwner(normAddr(a)),
|
||||
removePosition: (e, a) => impl().removePosition(normEmail(e), normAddr(a)),
|
||||
byMemberId: id => impl().byMemberId(id) };
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ const CANNED = [
|
||||
{ re: /(how (much|do i) earn|commission|percent|split)/i,
|
||||
a: 'Every package splits the same way: 50 percent to the direct sponsor, 20 percent to level 2, 10 percent to level 3, 20 percent to the platform. Those numbers are constants in the contract and cannot be changed. Play with scenarios on the home page calculator. No income is promised; nobody earns unless real ad buying happens.' },
|
||||
{ re: /(qualif|unlock level|level 2|level 3|pass.?up)/i,
|
||||
a: 'Level 1 is open to every member. Bring 2 buyers of $20 or more and level 2 unlocks; 5 unlock level 3. When a level is not qualified, its share climbs the sponsor line, checking up to 25 positions, and pays the first qualified person. Qualification never expires and cannot be bought.' },
|
||||
a: 'Level 1 is open to every member. Bring 2 buyers of $20 or more and level 2 unlocks; 5 unlock level 3. When a level is not qualified, its share climbs the sponsor line, checking up to 25 positions, and pays the first qualified person. Qualification never expires and cannot be bought. Qualified Start: you may link extra wallets of your own as positions under your account (Buy packages > Qualified Start); each one that buys a $20+ package counts as a qualifying buyer, its credits pool with yours, and 50% of its purchase comes back to your main wallet. Your own money, your own wallets, a faster start, never an income promise.' },
|
||||
{ re: /(need (a )?wallet|crypto experience|metamask|how (do i|to) join|sign ?up|register)/i,
|
||||
a: 'Join free with just your email at https://instantadpay.com/my, no wallet and no password needed. Your wallet only comes out when you buy a package or switch on payouts, and the site walks you through it.' },
|
||||
{ re: /(referral link|invite link|share link|refer)/i,
|
||||
|
||||
@@ -120,6 +120,15 @@ async function bootstrap() {
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN expires BIGINT NULL'); // featured rotation end time
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN starts BIGINT NULL'); // featured run start (booked day)
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN house TINYINT NOT NULL DEFAULT 0'); // admin house ad: free, never charged
|
||||
// linked positions: extra wallets owned by one email account (Qualified Start).
|
||||
// Each is its own on-chain member sponsored by the account's main member.
|
||||
await q(`CREATE TABLE IF NOT EXISTS positions (
|
||||
address VARCHAR(64) PRIMARY KEY,
|
||||
email VARCHAR(190) NOT NULL,
|
||||
member_id INT NOT NULL DEFAULT 0,
|
||||
created BIGINT NOT NULL,
|
||||
INDEX (email)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await q(`CREATE TABLE IF NOT EXISTS visit_seen (
|
||||
campaign_id INT NOT NULL,
|
||||
email VARCHAR(190) NOT NULL,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# InstantAdPay team-building plays
|
||||
|
||||
Working doc, 2026-09-09. Three ways to build a line, written so a member can pick one and run it. Every number below comes from the live contract and rate table (50 / 20 / 10 / 20 split; qualifying buyer = a direct who buys a $20+ package; level 2 opens at 2 qualifying, level 3 at 5; milestone credits 10 / 25 / 50 / 100; featured strip 40 credits a day; one broadcast a day; wall position 2 at 2 qualifying, position 3 at 5).
|
||||
|
||||
## The one rule under all three plays
|
||||
|
||||
Unqualified levels pass up. If someone on your level 2 buys before you have 2 qualifying buyers, that 20% does not wait for you. It goes to the next qualified sponsor above you (or the platform). Same for level 3 and 5. So whatever play you run, the first job is the same: get qualified before your line gets busy. The dashboard "Your next move" card is the ladder; the plays are how you climb it.
|
||||
|
||||
## Play 1: Wide and teach ("the fifty play")
|
||||
|
||||
Who it fits: someone with an audience, a list, a group, or traffic they can point somewhere. Time-rich or reach-rich.
|
||||
|
||||
Why it works: every direct who buys is 50% to you, instantly, forever. Directs are the only thing that qualifies you. And the teaching is what fills levels 2 and 3 without you doing anything extra: your directs' buyers are your 20%, their buyers are your 10%.
|
||||
|
||||
The move:
|
||||
1. One new conversation a day, minimum. Use the Text a friend and Social posts in Promo tools; every piece already carries your link.
|
||||
2. Point paid traffic at an angle lander, not the bare link: `?v=adspend` for advertisers, `?v=free` for freebie seekers, `?v=instant` for the crypto-curious.
|
||||
3. Every new direct gets the same three sentences from you inside 24 hours (sponsor chat or one broadcast): "Pick your username. Link your wallet and switch on payouts. Send your link to one person today." That is the whole teaching. They teach it to their people.
|
||||
4. Run the network's own ads at your link: buy a package, or claim the daily 5 credits, and spend the credits on a Featured link (40 credits a day) or a text ad pointed at your angle lander. Recruits from inside the network already understand the product.
|
||||
|
||||
Scoreboard: Joined your line (should climb daily), Qualifying buyers (2 then 5), then watch level 2 and 3 rows appear in My line.
|
||||
|
||||
Ceiling: none on width. Weakness: shallow lines churn if you skip step 3.
|
||||
|
||||
## Play 2: Two, then down ("the depth play")
|
||||
|
||||
Who it fits: someone with a small circle who would rather coach two people well than pitch twenty.
|
||||
|
||||
Why it works: two qualifying buyers open level 2 (20%), position 2 on your wall, the Circuit badge, and 50 bonus credits. From there, every person your two bring in pays you 20%, and every person those people bring in pays you 10% (once you reach 5). Your effort goes into two relationships instead of a funnel.
|
||||
|
||||
The move:
|
||||
1. Get two directs to a $20+ package. Sit with them on the buy if you have to (Trust Wallet needs a POL cushion; SafePal or MetaMask are smoother).
|
||||
2. Coach them to their two. Sponsor chat daily for the first week. One broadcast a day to your directs with a single ask each time.
|
||||
3. Set your line banner to your team's meeting place (Telegram group, a training page). Every new member three levels down meets it on their welcome tour. That is how your coaching reaches people you never directly recruited.
|
||||
4. Keep adding directs until you have five. This is the catch in the depth play: level 3 only opens on five qualifying directs of your own. Two deep, coached well, gets you a healthy 20% level. It does not get you the 10% level.
|
||||
|
||||
Scoreboard: Qualifying buyers 2, then level 2 count in My line rising, then your directs' own Qualifying buyers (ask them, or read their wall page).
|
||||
|
||||
Ceiling: level 2 income until you personally hit five. Strength: the stickiest lines come from this play.
|
||||
|
||||
## Play 3: Five and wide ("the combination", recommended default)
|
||||
|
||||
Who it fits: anyone willing to do both. This is the play the dashboard ladder is actually built for.
|
||||
|
||||
The move, in order:
|
||||
1. Sprint to five qualifying directs. Nothing else matters until level 3 is open: that is Nexus, wall position 3 (your whole public page runs your own links), 100 bonus credits, and the full 50/20/10.
|
||||
2. Then split the day. Mornings wide: one new conversation, one post, one ad running. Evenings deep: read My line, message the three newest directs, send the broadcast.
|
||||
3. Coach the 2-then-5 rule down the line. Each of your five gets pushed to two (your level 2 fills), then to five (your level 3 fills). Use the achievements Share links; people copy what they see rewarded.
|
||||
4. Book the featured strip for 7 days whenever you have 280 credits spare. Ten slots a day, every member sees it.
|
||||
|
||||
Scoreboard: all four tiles, plus Earning levels on the Overview (buyers referred, level open, how many to next).
|
||||
|
||||
## Which play fits you
|
||||
|
||||
| You have | Run |
|
||||
|---|---|
|
||||
| A list, a group, or ad budget | Wide and teach |
|
||||
| A few close people and patience | Two, then down |
|
||||
| An hour a day and a phone | Five and wide |
|
||||
|
||||
## First 30 days (any play)
|
||||
|
||||
- Day 1: username, wallet linked, payouts on, welcome tour done (25 credits). Send the link to one person.
|
||||
- Days 2 to 7: one conversation a day. Claim the daily 5 credits. First buyer (25 bonus credits).
|
||||
- Days 8 to 14: second qualifying buyer. Level 2 open. Set your line banner. First broadcast.
|
||||
- Days 15 to 30: coach the two to their two. Add directs three, four, five. Level 3 open by day 30 is the stretch goal.
|
||||
|
||||
## Messages that fit the plays
|
||||
|
||||
- Wide: "I run ads anyway. This one pays me in the same transaction the buyer's package sells, on a public ledger. Free to join by email: {{link}}"
|
||||
- Depth: "I need two people who will actually do this with me, not twenty who will look at it. You are one of the two I thought of. {{link}}"
|
||||
- Combo, to a new direct: "Three things today: username, wallet on, one person. I will check in tomorrow."
|
||||
|
||||
No income is guaranteed. Results depend on your effort. Crypto involves risk of loss. InstantAdPay sells advertising; it is not an investment.
|
||||
|
||||
## Where this should live on the site (proposal, not built)
|
||||
|
||||
- Promo tools: a "Plays" pill with the three plays and the fit table.
|
||||
- Sponsor coaching panel (pending build): the coach sees which play each direct is on, from their buyer count and line shape.
|
||||
- Training: a short video per play once the plays are approved.
|
||||
+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>
|
||||
|
||||
@@ -87,6 +87,14 @@ async function uplineSlides(email, depth = 3) {
|
||||
// thresholds that open payout levels 2 and 3). Until then, or while an unlocked
|
||||
// slot is empty, the slot shows an upline's banner, then a house ad.
|
||||
const wallUnlockedFor = bc => (bc >= 5 ? 3 : bc >= 2 ? 2 : 1);
|
||||
// every on-chain member id this session controls: the main wallet plus linked
|
||||
// positions (Qualified Start). Credits pool across them on the dashboard.
|
||||
async function myMemberIds(s) {
|
||||
const main = await auth.refreshMemberId(s);
|
||||
const ids = main ? [main] : [];
|
||||
if (s && s.email) for (const p of await accounts.positions(s.email)) if (p.memberId && !ids.includes(p.memberId)) ids.push(p.memberId);
|
||||
return { main, ids };
|
||||
}
|
||||
function parseWallOffers(a) {
|
||||
try { const v = JSON.parse((a && a.wallOffers) || '[]'); return Array.isArray(v) ? v.slice(0, 2) : []; } catch (e) { return []; }
|
||||
}
|
||||
@@ -653,6 +661,15 @@ const server = http.createServer(async (req, res) => {
|
||||
let memberId = 0;
|
||||
try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {}
|
||||
const s = await auth.fromRequest(req);
|
||||
if (s && s.email && b.asPosition) {
|
||||
// Qualified Start: a second (third…) wallet on the same account. It becomes
|
||||
// its own on-chain member under this member's id when it buys; the session
|
||||
// stays on the main wallet.
|
||||
const pr = await accounts.addPosition(s.email, r.address);
|
||||
if (pr.error) return json(res, 400, pr);
|
||||
if (memberId) await accounts.setPositionMember(r.address, memberId);
|
||||
return json(res, 200, { ok: true, position: true, address: r.address, memberId });
|
||||
}
|
||||
if (s && s.email) {
|
||||
const lr = await accounts.linkWallet(s.email, r.address);
|
||||
if (lr.error) return json(res, 400, lr);
|
||||
@@ -660,6 +677,8 @@ const server = http.createServer(async (req, res) => {
|
||||
return json(res, 200, { ok: true, linked: true, address: r.address, memberId });
|
||||
}
|
||||
const acct = await accounts.byAddress(r.address);
|
||||
if (!acct && await accounts.positionOwner(r.address))
|
||||
return json(res, 400, { error: 'That wallet is a linked position on an account. Sign in with that account\'s email instead.' });
|
||||
const token = await auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId });
|
||||
return json(res, 200, { ok: true, address: r.address, memberId },
|
||||
{ 'Set-Cookie': auth.sessionCookie(token) });
|
||||
@@ -696,7 +715,7 @@ const server = http.createServer(async (req, res) => {
|
||||
const mm = await chain.member(memberId);
|
||||
out.buyerCount = mm.buyerCount;
|
||||
out.onchainSponsorId = mm.sponsorId;
|
||||
out.credits = await chain.creditBalance(memberId, 0);
|
||||
out.credits = (await ads.pooledCredits((await myMemberIds(s)).ids)).total;
|
||||
} catch (e) { out.chainReadError = true; }
|
||||
}
|
||||
return json(res, 200, out);
|
||||
@@ -745,7 +764,7 @@ const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const mm = await chain.member(memberId);
|
||||
out.buyerCount = mm.buyerCount;
|
||||
out.credits = await ads.availableCredits(memberId);
|
||||
out.credits = (await ads.pooledCredits((await myMemberIds(s)).ids)).total;
|
||||
} catch (e) { out.chainReadError = true; }
|
||||
let earned = 0n, n = 0;
|
||||
for (const ev of chain.recentEvents(600)) {
|
||||
@@ -784,6 +803,38 @@ const server = http.createServer(async (req, res) => {
|
||||
out.wallUnlocked = wallUnlockedFor(out.buyerCount || 0); // how many wall positions are the member's own
|
||||
return json(res, 200, out);
|
||||
}
|
||||
// -- linked positions (Qualified Start): list, refresh from chain, unlink
|
||||
if (p === '/api/my/positions' && req.method === 'GET') {
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const acct = await accounts.byEmail(s.email);
|
||||
const mainId = await auth.refreshMemberId(s);
|
||||
const list = await accounts.positions(s.email);
|
||||
const out = [];
|
||||
for (const pos of list) {
|
||||
let id = pos.memberId;
|
||||
if (!id) { try { id = await chain.memberIdByAccount(pos.address); if (id) await accounts.setPositionMember(pos.address, id); } catch (e) {} }
|
||||
const row = { address: pos.address, memberId: id || 0, buyerCount: 0, counted: false, credits: 0, created: pos.created };
|
||||
if (id) {
|
||||
try { const mm = await chain.member(id); row.buyerCount = mm.buyerCount; row.counted = mm.countedAsBuyer; row.sponsorId = mm.sponsorId; } catch (e) {}
|
||||
try { row.credits = await ads.availableCredits(id); } catch (e) {}
|
||||
}
|
||||
out.push(row);
|
||||
}
|
||||
const main = { address: (acct && acct.address) || null, memberId: mainId, credits: 0, buyerCount: 0 };
|
||||
if (mainId) {
|
||||
try { main.credits = await ads.availableCredits(mainId); } catch (e) {}
|
||||
try { main.buyerCount = (await chain.member(mainId)).buyerCount; } catch (e) {}
|
||||
}
|
||||
return json(res, 200, { main, positions: out, totalCredits: main.credits + out.reduce((n, r) => n + r.credits, 0) });
|
||||
}
|
||||
if (p === '/api/my/positions/remove' && req.method === 'POST') {
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const b = await readBody(req);
|
||||
const r = await accounts.removePosition(s.email, b.address);
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
if (p === '/api/my/profile' && req.method === 'POST') {
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
@@ -898,6 +949,14 @@ const server = http.createServer(async (req, res) => {
|
||||
email: L.level === 1 ? m.email : null, // directs only
|
||||
joined: m.created,
|
||||
earnedWei: (m.memberId && earnedBy[m.memberId]) || '0' })) }));
|
||||
// the member's own linked positions sit on level 1 too, labelled as theirs
|
||||
const own = (await accounts.positions(s.email)).filter(p => p.memberId);
|
||||
if (own.length) {
|
||||
if (!out.find(L => L.level === 1)) out.unshift({ level: 1, members: [] });
|
||||
const L1 = out.find(L => L.level === 1);
|
||||
own.forEach((p, i) => L1.members.push({ memberId: p.memberId, name: 'You · position ' + (i + 2), own: true,
|
||||
email: null, joined: p.created, earnedWei: earnedBy[p.memberId] || '0' }));
|
||||
}
|
||||
return json(res, 200, { levels: out, counts: out.map(L => L.members.length) });
|
||||
}
|
||||
// -- broadcast a message to your downline (1/day), on-site inbox + email
|
||||
@@ -1386,7 +1445,10 @@ const server = http.createServer(async (req, res) => {
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const memberId = await auth.refreshMemberId(s);
|
||||
const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() };
|
||||
out.purchasedCredits = memberId ? await ads.availableCredits(memberId) : 0;
|
||||
const pool = await ads.pooledCredits((await myMemberIds(s)).ids);
|
||||
out.purchasedCredits = pool.total;
|
||||
out.largestPosition = pool.best.avail; // a single campaign budget has to fit one position
|
||||
out.positionCount = pool.per.length;
|
||||
out.earnedCredits = await ads.earnedBalance(s.email);
|
||||
out.availableCredits = out.purchasedCredits + out.earnedCredits;
|
||||
return json(res, 200, out);
|
||||
@@ -1400,7 +1462,15 @@ const server = http.createServer(async (req, res) => {
|
||||
const fc = await frameCheck(b.targetUrl);
|
||||
if (!fc.ok) return json(res, 400, { error: fc.reason });
|
||||
}
|
||||
const r = await ads.createCampaign(s.email, memberId, b);
|
||||
// charge the best-funded of the member's positions (main + Qualified Start
|
||||
// wallets). A campaign burns from one member id, so the budget must fit inside it.
|
||||
const pool = await ads.pooledCredits((await myMemberIds(s)).ids);
|
||||
const fundId = pool.best.memberId || memberId;
|
||||
const earnedNow = String(b.type) !== 'login' ? await ads.earnedBalance(s.email) : 0;
|
||||
const budget = Math.floor(Number(b.budget) || 0);
|
||||
if (pool.per.length > 1 && budget > pool.best.avail + earnedNow && budget <= pool.total + earnedNow)
|
||||
return json(res, 400, { error: 'Your credits are spread across ' + pool.per.length + ' positions and one campaign spends from one of them. The largest single position holds ' + pool.best.avail + ' credits: set the budget to that or less, or run two campaigns.' });
|
||||
const r = await ads.createCampaign(s.email, fundId, b);
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
m = /^\/api\/my\/campaigns\/(\d+)\/topup$/.exec(p);
|
||||
|
||||
Reference in New Issue
Block a user