Holding tank: unsponsored free members, first-come adoption (own $20 buy required, 2 open, 7-day window, twice max), release to tank, admin view; PIF wallet-to-wallet POL gift with logging

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-11 07:57:44 -05:00
parent 9939c0487f
commit 9709ce782d
10 changed files with 320 additions and 5 deletions
+2
View File
@@ -77,6 +77,8 @@ FACTS:
- Ad packages: Micro $5/500 credits, Activation $20/2,000, Builder $50/5,500, Growth $100/12,000, Leader $250/32,500. Dollar-priced, settled in POL (Polygon) at the live Chainlink rate. 1 credit = 1 cent of ad delivery. - Ad packages: Micro $5/500 credits, Activation $20/2,000, Builder $50/5,500, Growth $100/12,000, Leader $250/32,500. Dollar-priced, settled in POL (Polygon) at the live Chainlink rate. 1 credit = 1 cent of ad delivery.
- Live formats: display banners (per impression), text ads (per impression), full-screen LOGIN ADS (per day: right after a member signs in they land on a sponsor interstitial — they click "Open Ad", the advertiser's page opens in a NEW tab, a countdown runs on the interstitial, and at zero a "Go to dashboard" button appears. Just a CTA link is enough; an optional banner image can be the clickable creative. No framing requirement since it opens in its own tab), WATCH-TO-EARN VIDEO ADS (advertiser uploads an MP4/WebM or gives a direct https .mp4/.webm link and picks a required watch length — 10s/30s/60s — which sets the per-view price; viewers watch in an escape-proof player under Earn credits > Watch videos, the watch time is enforced on the server clock, and they earn credits per completed watch; you never see your own videos), and solo ads. Banner ads also require a size (standard IAB sizes like 728x90, 300x250). Coming: featured rotation with disclosed rotation size, verified-visit packs. - Live formats: display banners (per impression), text ads (per impression), full-screen LOGIN ADS (per day: right after a member signs in they land on a sponsor interstitial — they click "Open Ad", the advertiser's page opens in a NEW tab, a countdown runs on the interstitial, and at zero a "Go to dashboard" button appears. Just a CTA link is enough; an optional banner image can be the clickable creative. No framing requirement since it opens in its own tab), WATCH-TO-EARN VIDEO ADS (advertiser uploads an MP4/WebM or gives a direct https .mp4/.webm link and picks a required watch length — 10s/30s/60s — which sets the per-view price; viewers watch in an escape-proof player under Earn credits > Watch videos, the watch time is enforced on the server clock, and they earn credits per completed watch; you never see your own videos), and solo ads. Banner ads also require a size (standard IAB sizes like 728x90, 300x250). Coming: featured rotation with disclosed rotation size, verified-visit packs.
- WALLETS + BUYING POL (Training > Wallets and buying POL, /wallets, members only): preferred MetaMask (recommended; extra accounts for Qualified Start), Phantom, SafePal, Coinbase Wallet; Trust works but blocks buys spending most of its POL (keep ~2x). MoonPay flow: connect wallet, Buy packages > "Buy POL with a card" opens MoonPay with POL on Polygon + the member's address prefilled; card/Apple Pay/Google Pay; first-time ID check; minimum order ~$30; buy package cost + 2-3 POL for fees; POL arrives in minutes; then buy. Exchanges: withdraw POL on the Polygon network. Never MATIC on Ethereum, never share the recovery phrase. - WALLETS + BUYING POL (Training > Wallets and buying POL, /wallets, members only): preferred MetaMask (recommended; extra accounts for Qualified Start), Phantom, SafePal, Coinbase Wallet; Trust works but blocks buys spending most of its POL (keep ~2x). MoonPay flow: connect wallet, Buy packages > "Buy POL with a card" opens MoonPay with POL on Polygon + the member's address prefilled; card/Apple Pay/Google Pay; first-time ID check; minimum order ~$30; buy package cost + 2-3 POL for fees; POL arrives in minutes; then buy. Exchanges: withdraw POL on the Polygon network. Never MATIC on Ethereum, never share the recovery phrase.
- HOLDING TANK (Members > My line > Holding tank card): free members who joined with no sponsor wait there; a member who has switched on payouts AND bought their own $20+ package can Adopt one (first come, max 2 open adoptions, 7-day window; if the person never links a wallet or buys, they fall back into the tank; a person can be adopted twice at most). Adopting sets the sponsor, opens a chat and emails the member; their first purchase then binds to the adopter on-chain. Members can also "Release to tank" one of their own free referrals (pay it forward). Admin sees the tank under Members.
- PIF (pay it forward) button: on a free direct or an adopted member who has linked a wallet, the sponsor taps PIF, enters an amount (suggested: the $20 package plus fees), and their OWN wallet app opens with the member's address prefilled; the POL goes wallet to wallet. The site never touches the funds; it only logs the transaction and tells the recipient with a Polygonscan link. The gift is theirs; nothing forces a purchase.
- FOUNDING WEEK / PRE-LAUNCH (Training > Founding week checklist, /launch, members only): eight items read live from the account: username, wallet linked, payouts on, level 2 qualified (2 buyers of $20+, or Qualified Start with 2 linked positions), the leader play = level 3 (5 qualifying buyers, up to 5 linked positions; then buy from the main wallet), line banner, links + play chosen (self-marked), first two placed. Reason: unqualified levels pass up, so leaders qualify BEFORE their teams' teams buy. Countdown shows when admin sets launchAt. Never call the site 'pre-launch' publicly: it is live and paying. - FOUNDING WEEK / PRE-LAUNCH (Training > Founding week checklist, /launch, members only): eight items read live from the account: username, wallet linked, payouts on, level 2 qualified (2 buyers of $20+, or Qualified Start with 2 linked positions), the leader play = level 3 (5 qualifying buyers, up to 5 linked positions; then buy from the main wallet), line banner, links + play chosen (self-marked), first two placed. Reason: unqualified levels pass up, so leaders qualify BEFORE their teams' teams buy. Countdown shows when admin sets launchAt. Never call the site 'pre-launch' publicly: it is live and paying.
- SCHEDULING (2026-09-11): any campaign except featured can take an optional start and end time (local time) in the New campaign form; solo ads label it "Send from" (inbox deliveries begin then). A scheduled campaign shows "scheduled" until it starts; at the end it shows "ended" and the unspent budget returns to Available. Banner/text scheduled campaigns join the partner network at their start time. There is NO dayparting (hours-of-day targeting) by design; the daily cap paces budgets. Each campaign row shows a small views-by-hour chart (on-site views, viewer's local time, last 7 days). - SCHEDULING (2026-09-11): any campaign except featured can take an optional start and end time (local time) in the New campaign form; solo ads label it "Send from" (inbox deliveries begin then). A scheduled campaign shows "scheduled" until it starts; at the end it shows "ended" and the unspent budget returns to Available. Banner/text scheduled campaigns join the partner network at their start time. There is NO dayparting (hours-of-day targeting) by design; the daily cap paces budgets. Each campaign row shows a small views-by-hour chart (on-site views, viewer's local time, last 7 days).
- BALANCE RULE (members ask this a lot): a balance is what is NOT committed to a live campaign. Starting a campaign sets aside its whole budget at once (earned pool first, then purchased), so Available drops once and stays still while ads serve; the budget spends down inside Members > Campaigns. Dashboard shows Purchased available, Earned available, In live campaigns. A paused campaign keeps its unspent budget set aside so it can resume. A low balance with a live campaign is not lost credits. Refunds/comps of purchased money are credited off-chain as purchased-grade credits: shown under Purchased as "credited to you", fund anything incl. login ads. Viewing-earned credits never fund login ads. - BALANCE RULE (members ask this a lot): a balance is what is NOT committed to a live campaign. Starting a campaign sets aside its whole budget at once (earned pool first, then purchased), so Available drops once and stays still while ads serve; the budget spends down inside Members > Campaigns. Dashboard shows Purchased available, Earned available, In live campaigns. A paused campaign keeps its unspent budget set aside so it can resume. A low balance with a live campaign is not lost credits. Refunds/comps of purchased money are credited off-chain as purchased-grade credits: shown under Purchased as "credited to you", fund anything incl. login ads. Viewing-earned credits never fund login ads.
+2
View File
@@ -76,6 +76,8 @@ async function coachView(email) {
// bound: the contract pays whoever the member registered under. A direct who activated with // bound: the contract pays whoever the member registered under. A direct who activated with
// no sponsor (or a different one) is in this line on the site but pays this member nothing. // no sponsor (or a different one) is in this line on the site but pays this member nothing.
c.bound = c.onchainSponsorId == null ? null : (myId > 0 && c.onchainSponsorId === myId); c.bound = c.onchainSponsorId == null ? null : (myId > 0 && c.onchainSponsorId === myId);
c.free = !d.memberId; // still free: can be released to the holding tank (pay it forward)
c.address = (!d.memberId && d.address) ? d.address : null; // linked wallet of a free direct: the PIF gift target
out.push(Object.assign({ email: d.email, name: d.username ? '@' + d.username : (d.memberId ? 'member #' + d.memberId : d.email.replace(/^(.).*(@.*)$/, '$1***$2')), memberId: d.memberId || 0 }, c)); out.push(Object.assign({ email: d.email, name: d.username ? '@' + d.username : (d.memberId ? 'member #' + d.memberId : d.email.replace(/^(.).*(@.*)$/, '$1***$2')), memberId: d.memberId || 0 }, c));
} }
// most actionable first: stalled lowest rung, then quiet days // most actionable first: stalled lowest rung, then quiet days
+7
View File
@@ -139,6 +139,13 @@ async function bootstrap() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS nudges (email VARCHAR(190) PRIMARY KEY, rung INT NOT NULL, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await q(`CREATE TABLE IF NOT EXISTS nudges (email VARCHAR(190) PRIMARY KEY, rung INT NOT NULL, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS digests (email VARCHAR(190) PRIMARY KEY, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await q(`CREATE TABLE IF NOT EXISTS digests (email VARCHAR(190) PRIMARY KEY, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS adoptions (
id INT AUTO_INCREMENT PRIMARY KEY,
adoptee VARCHAR(190) NOT NULL, adopter VARCHAR(190) NOT NULL,
ts BIGINT NOT NULL, expires BIGINT NOT NULL, status VARCHAR(12) NOT NULL DEFAULT 'open',
note VARCHAR(600) NULL, closed BIGINT NULL,
INDEX (adoptee), INDEX (adopter), INDEX (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // holding-tank adoptions
await q(`CREATE TABLE IF NOT EXISTS prospects ( await q(`CREATE TABLE IF NOT EXISTS prospects (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
owner_email VARCHAR(190) NOT NULL, owner_email VARCHAR(190) NOT NULL,
+7 -1
View File
@@ -226,6 +226,12 @@
</div> </div>
<div class="pane" id="pane-members" hidden> <div class="pane" id="pane-members" hidden>
<div class="card">
<div class="card-head"><h3>Holding tank</h3><span class="sub" id="tankAdmSub">free members with no sponsor, and who adopted whom</span></div>
<div class="tablewrap"><table class="adm-table" id="tankWait"></table></div>
<p class="small muted" style="margin:12px 0 6px">Adoptions (newest first)</p>
<div class="tablewrap"><table class="adm-table" id="tankAdopt"></table></div>
</div>
<div class="card"> <div class="card">
<div class="card-head"><h3>Members</h3><span class="sub" id="memSub">newest first</span></div> <div class="card-head"><h3>Members</h3><span class="sub" id="memSub">newest first</span></div>
<p style="margin:0 0 10px"><input id="memFilter" placeholder="Filter by email, username, member # or sponsor" style="width:100%"></p> <p style="margin:0 0 10px"><input id="memFilter" placeholder="Filter by email, username, member # or sponsor" style="width:100%"></p>
@@ -310,6 +316,6 @@
</div> </div>
<script src="/assets/common.js?v=20260910c"></script> <script src="/assets/common.js?v=20260910c"></script>
<script src="/assets/admin.js?v=20260911b"></script> <script src="/assets/admin.js?v=20260911c"></script>
</body> </body>
</html> </html>
+9
View File
@@ -267,7 +267,16 @@
// ── members ── // ── members ──
let allMembers = []; let allMembers = [];
async function loadTank() {
try {
const r = await (await fetch('/api/admin/tank')).json(); if (r.error) return;
$('tankAdmSub').textContent = r.waiting.length + ' waiting · cap ' + r.cap + ' open per adopter · ' + r.ttlDays + '-day window';
$('tankWait').innerHTML = '<tr><th>Waiting</th><th>Email</th><th>Joined</th><th>Last sign-in</th></tr>' + (r.waiting.length ? r.waiting.map(w => '<tr><td>' + esc(w.name) + '</td><td>' + esc(w.email) + '</td><td class="when">' + when(w.joined) + '</td><td class="when">' + (w.lastSeen ? when(w.lastSeen) : '<span class="muted">never</span>') + '</td></tr>').join('') : '<tr><td colspan="4" class="muted">empty</td></tr>');
$('tankAdopt').innerHTML = '<tr><th>Member</th><th>Adopted by</th><th>When</th><th>Window ends</th><th>Status</th></tr>' + (r.adoptions.length ? r.adoptions.map(a => '<tr><td>' + esc(a.adopteeName) + '</td><td>' + esc(a.adopterName) + '</td><td class="when">' + when(a.ts) + '</td><td class="when">' + (a.status === 'released' ? '' : when(a.expires)) + '</td><td>' + esc(a.status) + '</td></tr>').join('') : '<tr><td colspan="5" class="muted">none yet</td></tr>');
} catch (e) {}
}
async function loadMembers() { async function loadMembers() {
loadTank();
const r = await api('/api/admin/members'); const r = await api('/api/admin/members');
allMembers = r.members || []; allMembers = r.members || [];
drawMembers(); drawMembers();
+56 -1
View File
@@ -1011,7 +1011,54 @@
} }
// ── coaching: every direct's rung, stalled flag, one-click nudge ── // ── coaching: every direct's rung, stalled flag, one-click nudge ──
// ── pay it forward: send POL from the sponsor's own wallet to a downline's linked address ──
async function pif(email, name, address) {
let suggest = 25;
try { const { products } = await (await fetch('/api/catalog')).json(); const p20 = (products || []).find(p => p.priceCents === 2000); if (p20 && p20.costWei) suggest = Math.ceil(Number(p20.costWei) / 1e18) + 3; } catch (e) {}
const amt = prompt('Send POL from your wallet to ' + name + ' (' + address.slice(0, 6) + '…' + address.slice(-4) + ') for their first package.\nSuggested: the $20 package plus fees. Amount in POL:', String(suggest));
if (amt === null) return;
const pol = Number(amt); if (!(pol > 0)) { IAP.status('Enter an amount in POL.', 'bad'); return; }
try {
IAP.status('Confirm the transfer in your wallet…', 'ok');
const wei = (BigInt(Math.round(pol * 1e6)) * 10n ** 12n).toString();
const hash = await IAPWallet.sendPol(address, wei);
await api('/api/my/gift', { email, tx: hash, pol });
IAP.status('Sent ' + pol + ' POL to ' + name + '. They have been told, with the proof link.', 'ok');
playSound && playSound('chaching');
} catch (e) { IAP.status('Transfer not sent: ' + ((e && e.message) || e), 'bad'); }
}
// ── holding tank: waiting members, adopt, my open adoptions ──
const ago = ts => { if (!ts) return 'never'; const d = Math.floor((Date.now() - ts) / 86400000); return d === 0 ? 'today' : d === 1 ? 'yesterday' : d + ' days ago'; };
async function loadTank() {
const el = $('tankList'); if (!el) return;
try {
const r = await (await fetch('/api/my/tank')).json();
if (r.error) { el.innerHTML = ''; return; }
$('tankCap').textContent = r.cap; $('tankTtl').textContent = r.ttlDays;
$('tankSub').textContent = r.waiting.length ? r.waiting.length + ' waiting' : 'nobody waiting right now';
const why = $('tankWhy'); why.hidden = r.eligible; why.innerHTML = r.eligible ? '' : '<span class="badge amber">not yet</span> ' + esc(r.reason);
$('tankMine').innerHTML = r.mine.length ? '<p class="small" style="margin:0 0 6px"><b>Your open adoptions</b></p>' + r.mine.map(m => '<div class="lin-row own"><span class="nm">' + esc(m.name) + '</span>'
+ '<span class="em">' + (m.bought ? 'bought' : m.wallet ? 'wallet linked' : 'free, no wallet yet') + ' · last seen ' + ago(m.lastSeen) + '</span>'
+ '<span class="dt">' + Math.max(0, Math.ceil((m.expires - Date.now()) / 86400000)) + ' days left</span>'
+ '<button class="btn sec small" type="button" data-tchat="' + esc(m.email) + '" data-tname="' + esc(m.name) + '">Chat</button>'
+ (m.address && !m.bought ? ' <button class="btn small" type="button" title="Pay it forward: send POL from your wallet to theirs for their first package" data-pif="' + esc(m.email) + '" data-pname="' + esc(m.name) + '" data-paddr="' + esc(m.address) + '">PIF</button>' : '') + '</div>').join('') : '';
$('tankMine').querySelectorAll('[data-tchat]').forEach(b => b.addEventListener('click', () => openConvo(b.dataset.tchat, b.dataset.tname)));
$('tankMine').querySelectorAll('[data-pif]').forEach(b => b.addEventListener('click', () => pif(b.dataset.pif, b.dataset.pname, b.dataset.paddr)));
if (!r.waiting.length) { el.innerHTML = '<p class="muted small">The tank is empty. Anyone who joins from the public site without a sponsor lands here.</p>'; return; }
el.innerHTML = r.waiting.map(w => '<div class="lin-row"><span class="nm">' + esc(w.name) + '</span>'
+ '<span class="em">joined ' + ago(w.joined) + '</span>'
+ '<span class="dt">last sign-in: <b>' + ago(w.lastSeen) + '</b></span>'
+ (r.eligible ? '<button class="btn small" type="button" data-adopt="' + esc(w.username || w.email) + '" data-aname="' + esc(w.name) + '">Adopt</button>' : '') + '</div>').join('');
el.querySelectorAll('[data-adopt]').forEach(b => b.addEventListener('click', async () => {
const note = prompt('Your first message to ' + b.dataset.aname + ' (sent as a chat and an email):', 'Hi, I picked you up from the InstantAdPay holding tank so you have a sponsor who will actually help. Reply here and I will walk you through the first three steps.');
if (note === null) return;
try { const rr = await api('/api/my/tank/adopt', { who: b.dataset.adopt, note }); IAP.status('You are now the sponsor for ' + rr.name + '. Chat and email sent.', 'ok'); loadTank(); loadCoach(); }
catch (e) { IAP.status(e.message, 'bad'); }
}));
} catch (e) {}
}
async function loadCoach() { async function loadCoach() {
loadTank();
try { try {
const r = await (await fetch('/api/my/coach')).json(); const r = await (await fetch('/api/my/coach')).json();
const el = $('coachList'); if (!el || r.error) return; const el = $('coachList'); if (!el || r.error) return;
@@ -1022,11 +1069,19 @@
+ '<span class="em">' + esc(x.label) + ' → ' + esc(x.next) + '</span>' + '<span class="em">' + esc(x.label) + ' → ' + esc(x.next) + '</span>'
+ '<span class="id">rung ' + x.rung + '/6</span>' + '<span class="id">rung ' + x.rung + '/6</span>'
+ '<span class="dt">' + (x.buyerCount ? x.buyerCount + ' buyer' + (x.buyerCount === 1 ? '' : 's') : '') + '</span>' + '<span class="dt">' + (x.buyerCount ? x.buyerCount + ' buyer' + (x.buyerCount === 1 ? '' : 's') : '') + '</span>'
+ '<button class="btn sec small" type="button" data-nudge="' + esc(x.email) + '" data-nname="' + esc(x.name) + '" data-say="' + esc(x.say) + '">Nudge</button></div>').join(''); + '<button class="btn sec small" type="button" data-nudge="' + esc(x.email) + '" data-nname="' + esc(x.name) + '" data-say="' + esc(x.say) + '">Nudge</button>'
+ (x.free && x.address ? ' <button class="btn small" type="button" title="Pay it forward: send POL from your wallet to theirs for their first package" data-pif="' + esc(x.email) + '" data-pname="' + esc(x.name) + '" data-paddr="' + esc(x.address) + '">PIF</button>' : '')
+ (x.free ? ' <button class="btn sec small" type="button" title="Pay it forward: give this free member to the holding tank so another member can coach them" data-release="' + esc(x.email) + '" data-rname="' + esc(x.name) + '">Release to tank</button>' : '') + '</div>').join('');
el.querySelectorAll('[data-pif]').forEach(b => b.addEventListener('click', () => pif(b.dataset.pif, b.dataset.pname, b.dataset.paddr)));
el.querySelectorAll('[data-nudge]').forEach(b => b.addEventListener('click', async () => { el.querySelectorAll('[data-nudge]').forEach(b => b.addEventListener('click', async () => {
await openConvo(b.dataset.nudge, b.dataset.nname); await openConvo(b.dataset.nudge, b.dataset.nname);
const inp = $('chatInput'); if (inp) { inp.value = b.dataset.say.replace(/\{\{name\}\}/g, b.dataset.nname.replace(/^@/, '')); inp.focus(); } const inp = $('chatInput'); if (inp) { inp.value = b.dataset.say.replace(/\{\{name\}\}/g, b.dataset.nname.replace(/^@/, '')); inp.focus(); }
})); }));
el.querySelectorAll('[data-release]').forEach(b => b.addEventListener('click', async () => {
if (!confirm('Release ' + b.dataset.rname + ' to the holding tank? You stop being their sponsor and another member can adopt them.')) return;
try { await api('/api/my/tank/release', { email: b.dataset.release }); IAP.status(b.dataset.rname + ' is in the holding tank.', 'ok'); loadCoach(); }
catch (e) { IAP.status(e.message, 'bad'); }
}));
} catch (e) {} } catch (e) {}
} }
// schedule chips on the campaign table (local time) + by-hour view bars // schedule chips on the campaign table (local time) + by-hour view bars
+17 -1
View File
@@ -226,6 +226,22 @@ window.IAPWallet = (function () {
} }
} }
// pay it forward: send POL straight from the sponsor's wallet to a downline member's
// linked address. A native transfer, no contract, no site custody: the wallet app
// shows the prefilled recipient and amount and the sponsor confirms there.
async function sendPol(toAddress, valueWei) {
if (!/^0x[0-9a-fA-F]{40}$/.test(String(toAddress || ''))) throw new Error('That member has no wallet address on file yet.');
const c = await IAP.getConfig();
const addr = await connect();
const wantNum = chainNum(c.chainId);
let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {}
if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum) { await ensureChain(c); }
const tx = { from: await activeAddress(addr), to: toAddress, value: '0x' + BigInt(valueWei).toString(16) };
try { const g = await (await fetch('/api/gas')).json(); if (g && g.maxPriorityFeePerGas && g.maxFeePerGas) { tx.maxPriorityFeePerGas = g.maxPriorityFeePerGas; tx.maxFeePerGas = g.maxFeePerGas; } } catch (e) {}
try { return await eth().request({ method: 'eth_sendTransaction', params: [tx] }); }
catch (e) { if (!isDead(e)) throw e; tx.from = await freshConnect(c); return eth().request({ method: 'eth_sendTransaction', params: [tx] }); }
}
async function waitTx(hash) { async function waitTx(hash) {
for (let i = 0; i < 90; i++) { for (let i = 0; i < 90; i++) {
try { try {
@@ -264,5 +280,5 @@ window.IAPWallet = (function () {
return BigInt(h); return BigInt(h);
} }
function walletName() { try { const w = modal && modal.getWalletInfo && modal.getWalletInfo(); return (w && w.name) || ''; } catch (e) { return ''; } } function walletName() { try { const w = modal && modal.getWalletInfo && modal.getWalletInfo(); return (w && w.name) || ''; } catch (e) { return ''; } }
return { connect, signIn, buy, activate, waitTx, disconnect, balance, address: currentAddress, activeAddress, pickAccount, walletName }; return { connect, signIn, buy, activate, sendPol, waitTx, disconnect, balance, address: currentAddress, activeAddress, pickAccount, walletName };
})(); })();
+9 -2
View File
@@ -293,6 +293,13 @@
first package of $20 or more, they count toward your qualification.</p> first package of $20 or more, they count toward your qualification.</p>
<div id="rosterWrap"><p class="muted small" id="rosterEmpty">Nobody yet. Your link is ready above; share it and this list starts filling.</p></div> <div id="rosterWrap"><p class="muted small" id="rosterEmpty">Nobody yet. Your link is ready above; share it and this list starts filling.</p></div>
</div> </div>
<div class="card" id="tankCard">
<div class="card-head"><h3>Holding tank</h3><span class="sub" id="tankSub">members who arrived with no sponsor</span></div>
<p class="muted small">People who joined without a sponsor wait here. Adopt one and you become their sponsor: a chat opens, they get an email with your message, and their first purchase binds to you on the contract. First come. At most <b id="tankCap">2</b> open adoptions at a time, and an adoption goes back to the tank after <b id="tankTtl">7</b> days if they have not linked a wallet or bought.</p>
<p class="small" id="tankWhy" hidden></p>
<div id="tankMine"></div>
<div id="tankList"><p class="muted small">Loading…</p></div>
</div>
<div class="card" id="coachCard"> <div class="card" id="coachCard">
<h3>Coach your directs</h3> <h3>Coach your directs</h3>
<p class="muted small">Where each person you referred sits on the ladder, who has gone quiet, and what to say next. <b>Nudge</b> opens a chat with the message already written; edit it if you like, then send. Quiet members also get one automatic reminder email per step from InstantAdPay, so your nudge is the personal one.</p> <p class="muted small">Where each person you referred sits on the ladder, who has gone quiet, and what to say next. <b>Nudge</b> opens a chat with the message already written; edit it if you like, then send. Quiet members also get one automatic reminder email per step from InstantAdPay, so your nudge is the personal one.</p>
@@ -890,9 +897,9 @@
</div> </div>
</div> </div>
<script src="/assets/common.js?v=20260911b"></script> <script src="/assets/common.js?v=20260911b"></script>
<script src="/assets/wallet.js?v=20260910a"></script> <script src="/assets/wallet.js?v=20260911a"></script>
<script src="/assets/promo.js?v=20260911a"></script> <script src="/assets/promo.js?v=20260911a"></script>
<script src="/assets/my.js?v=20260911c"></script> <script src="/assets/my.js?v=20260911e"></script>
<script src="/assets/chat.js?v=20260907l"></script> <script src="/assets/chat.js?v=20260907l"></script>
</body> </body>
</html> </html>
+34
View File
@@ -24,6 +24,7 @@ const spaces = require('./spaces'); // DO Spaces video storage (inert unless DO_
let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ } let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ }
const chatbot = require('./chatbot'); const chatbot = require('./chatbot');
const coach = require('./coach'); // coaching view, nudges, digest, prospects, link stats const coach = require('./coach'); // coaching view, nudges, digest, prospects, link stats
const tank = require('./tank'); // holding tank: unsponsored free members, adoptions, pay-it-forward
const burner = require('./burner'); // automatic on-chain credit burns (inert without ENGINE_KEY) const burner = require('./burner'); // automatic on-chain credit burns (inert without ENGINE_KEY)
const PORT = Number(process.env.PORT || 3000); const PORT = Number(process.env.PORT || 3000);
@@ -301,6 +302,8 @@ async function boot() {
setInterval(() => ads.scheduleSweep().catch(e => console.error('schedule sweep', e.message)), 5 * 60 * 1000); // scheduled starts/ends setInterval(() => ads.scheduleSweep().catch(e => console.error('schedule sweep', e.message)), 5 * 60 * 1000); // scheduled starts/ends
// follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot) // follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot)
coach.init({ dataDir: DATA_DIR, chain, accounts, mailer }); coach.init({ dataDir: DATA_DIR, chain, accounts, mailer });
tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' });
setInterval(() => tank.sweep().catch(e => console.error('tank sweep', e.message)), 60 * 60 * 1000); // adoptions past their 7-day window
burner.init({ chain, ads }); burner.init({ chain, ads });
setTimeout(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 90 * 1000); setTimeout(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 90 * 1000);
setInterval(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 60 * 60 * 1000); setInterval(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 60 * 60 * 1000);
@@ -980,6 +983,37 @@ const server = http.createServer(async (req, res) => {
return json(res, r.error ? 400 : 200, r); return json(res, r.error ? 400 : 200, r);
} }
// -- coaching: every direct's ladder rung, stalled flag, and what to say // -- coaching: every direct's ladder rung, stalled flag, and what to say
// -- holding tank: waiting members, my adoptions, adopt, release (pay it forward)
if (p === '/api/my/tank' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await tank.view(s.email));
}
if (p === '/api/my/tank/adopt' && 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 tank.adopt(s.email, b.who, b.note);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/my/tank/release' && 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 tank.release(s.email, b.email);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/my/gift' && req.method === 'POST') { // PIF: log a wallet-to-wallet POL gift and tell the recipient
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 tank.recordGift(s.email, b.email, b.tx, b.pol);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/admin/tank' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, await tank.adminView());
}
if (p === '/api/my/coach' && req.method === 'GET') { if (p === '/api/my/coach' && req.method === 'GET') {
const s = await auth.fromRequest(req); const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
+177
View File
@@ -0,0 +1,177 @@
'use strict';
// Holding tank (Marty, 2026-09-11): free members who arrived with no sponsor wait
// here, and a member who has bought their own $20+ package can adopt one:
// first come, at most two open adoptions at a time, an adoption falls back into
// the tank after 7 days if the person never linked a wallet or bought, and a
// person can be adopted twice at most before they stay wherever they are.
// Members can also release one of their own free referrals into the tank
// (pay it forward). The contract binds sponsor at first purchase, so every
// hand-off here is a site record until then.
const fs = require('fs');
const path = require('path');
const db = require('./db');
const CAP_OPEN = 2; // open adoptions per adopter
const TTL_MS = 7 * 86400000; // an adoption's window to convert
const MAX_ADOPTIONS = 2; // per adoptee, lifetime
const MIN_OWN_BUY_CENTS = 2000; // adopter must have bought a $20+ package themselves
let DATA_DIR = '.', accounts, chain, messages, mailer, siteUrl = 'https://instantadpay.com';
const J = {
db: { v: 1, nextId: 1, adoptions: [] },
FILE: () => path.join(DATA_DIR, 'tank.json'),
load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} },
async add(a) { const row = Object.assign({ id: this.db.nextId++ }, a); this.db.adoptions.push(row); this.save(); return row; },
async open(adopter) { return this.db.adoptions.filter(x => x.status === 'open' && (!adopter || x.adopter === adopter)); },
async countFor(adoptee) { return this.db.adoptions.filter(x => x.adoptee === adoptee && x.status !== 'released').length; },
async setStatus(id, status) { const x = this.db.adoptions.find(r => r.id === id); if (x) { x.status = status; x.closed = Date.now(); this.save(); } },
async recent(n) { return this.db.adoptions.slice(-(n || 100)).reverse(); }
};
const D = {
async add(a) {
const r = await db.q('INSERT INTO adoptions (adoptee,adopter,ts,expires,status,note) VALUES (?,?,?,?,?,?)', [a.adoptee, a.adopter, a.ts, a.expires, a.status, a.note || null]);
return Object.assign({ id: r.insertId }, a);
},
async open(adopter) {
const rows = adopter ? await db.q("SELECT * FROM adoptions WHERE status='open' AND adopter=?", [adopter]) : await db.q("SELECT * FROM adoptions WHERE status='open'");
return rows.map(rowA);
},
async countFor(adoptee) { const r = await db.q("SELECT COUNT(*) n FROM adoptions WHERE adoptee=? AND status<>'released'", [adoptee]); return Number(r[0].n); },
async setStatus(id, status) { await db.q('UPDATE adoptions SET status=?, closed=? WHERE id=?', [status, Date.now(), Number(id)]); },
async recent(n) { return (await db.q('SELECT * FROM adoptions ORDER BY id DESC LIMIT ?', [Number(n) || 100])).map(rowA); }
};
const rowA = r => ({ id: r.id, adoptee: r.adoptee, adopter: r.adopter, ts: Number(r.ts), expires: Number(r.expires), status: r.status, note: r.note || null, closed: r.closed ? Number(r.closed) : null });
const impl = () => db.enabled() ? D : J;
function init(opts) { DATA_DIR = opts.dataDir; accounts = opts.accounts; chain = opts.chain; messages = opts.messages; mailer = opts.mailer; if (opts.site) siteUrl = opts.site; J.load(); }
const mask = e => String(e || '').replace(/^(.).*(@.*)$/, '$1***$2');
const nameOf = a => a.username ? '@' + a.username : (a.memberId ? 'member #' + a.memberId : mask(a.email));
const inTank = a => !a.sponsorRef && !a.memberId; // arrived with no sponsor (or fell back), still free
// waiting list: newest sign-in first so a live one is easy to spot
async function waiting() {
const all = await accounts.listAll(2000);
return all.filter(inTank).map(a => ({ email: a.email, name: nameOf(a), username: a.username || null, joined: a.created, lastSeen: a.lastSeen || 0, wallet: false }))
.sort((a, b) => (b.lastSeen || b.joined) - (a.lastSeen || a.joined));
}
// has this member bought a $20+ package themselves? (the qualifying-for-yourself buy)
function hasOwnBuy(memberId) {
if (!memberId) return false;
for (const ev of chain.recentEvents(200000)) if (ev.type === 'Purchase' && ev.buyerId === memberId && Number(ev.priceCents) >= MIN_OWN_BUY_CENTS) return true;
return false;
}
async function eligibility(email) {
const a = await accounts.byEmail(email);
if (!a) return { ok: false, reason: 'Sign in first.' };
if (!a.memberId) return { ok: false, reason: 'Switch on payouts and buy your first $20 package to adopt from the tank.' };
if (!hasOwnBuy(a.memberId)) return { ok: false, reason: 'Buy your own $20 or more package first. Adopting is for members who have made that move themselves.' };
const open = await impl().open(a.email);
if (open.length >= CAP_OPEN) return { ok: false, reason: 'You have ' + CAP_OPEN + ' open adoptions. Help one of them link a wallet or buy, and a slot frees up.', full: true };
return { ok: true, account: a, open };
}
async function view(email) {
const e = String(email || '').toLowerCase();
const el = await eligibility(e);
const mine = [];
for (const ad of await impl().open(e)) {
const a = await accounts.byEmail(ad.adoptee);
mine.push({ id: ad.id, name: a ? nameOf(a) : mask(ad.adoptee), email: ad.adoptee, ts: ad.ts, expires: ad.expires, lastSeen: a ? (a.lastSeen || 0) : 0, wallet: !!(a && a.address), address: (a && a.address) || null, bought: !!(a && a.memberId) });
}
return { eligible: el.ok, reason: el.ok ? '' : el.reason, cap: CAP_OPEN, ttlDays: TTL_MS / 86400000, waiting: await waiting(), mine };
}
async function adopt(adopterEmail, who, note) {
const e = String(adopterEmail || '').toLowerCase();
const el = await eligibility(e);
if (!el.ok) return { error: el.reason };
const me = el.account;
const key = String(who || '').trim().toLowerCase().replace(/^@/, '');
const list = await waiting();
const target = list.find(w => (w.username && w.username.toLowerCase() === key) || w.email === key);
if (!target) return { error: 'That member is no longer in the tank.' };
if (target.email === e) return { error: 'That is you.' };
if (await impl().countFor(target.email) >= MAX_ADOPTIONS) return { error: 'That member has been adopted twice already and stays where they are.' };
const token = me.username || me.code || String(me.memberId);
const r = await accounts.setSponsorRef(target.email, token);
if (r.error) return r;
const now = Date.now();
const text = String(note || '').trim().slice(0, 600) || ('Hi, I am ' + nameOf(me) + '. You joined InstantAdPay without a sponsor, so I picked you up from the holding tank. I will walk you through the first three steps whenever you are ready. Reply here.');
const ad = await impl().add({ adoptee: target.email, adopter: e, ts: now, expires: now + TTL_MS, status: 'open', note: text });
try { await messages.sendChat(me.memberId || 0, e, target.email, text); } catch (err) {}
if (mailer && mailer.hasKey()) {
try {
await mailer.send(target.email, nameOf(me) + ' is now your sponsor on InstantAdPay',
'You joined InstantAdPay without a sponsor. ' + nameOf(me) + ' has picked you up from the holding tank and is your sponsor now, which means a real person to walk you through the first steps.\n\nTheir message:\n\n' + text + '\n\nReply in your member area: ' + siteUrl + '/my#messages\n\nInstantAdPay');
} catch (err) {}
}
return { ok: true, adoption: ad, name: target.name };
}
// pay it forward: give one of your own free referrals to the tank
async function release(ownerEmail, directEmail) {
const o = String(ownerEmail || '').toLowerCase(), d = String(directEmail || '').toLowerCase();
const owner = await accounts.byEmail(o), direct = await accounts.byEmail(d);
if (!owner || !direct) return { error: 'No such member.' };
const toks = [owner.code, owner.username, owner.memberId ? String(owner.memberId) : null].filter(Boolean).map(String);
if (!toks.includes(String(direct.sponsorRef || ''))) return { error: 'That member is not in your line.' };
if (direct.memberId) return { error: 'That member has already bought; on-chain sponsorship cannot move.' };
const r = await accounts.setSponsorRef(d, '');
if (r.error) return r;
// an open adoption of this person closes as released (it no longer counts toward their two)
let closed = 0;
for (const ad of await impl().open(null)) if (ad.adoptee === d) { await impl().setStatus(ad.id, 'released'); closed++; }
if (!closed) await impl().add({ adoptee: d, adopter: o, ts: Date.now(), expires: Date.now(), status: 'released', note: 'released to the tank' });
return { ok: true };
}
// open adoptions past their window: converted ones close as done; the rest fall
// back into the tank unless the person has been adopted twice already
async function sweep() {
const now = Date.now();
let done = 0, back = 0, kept = 0;
for (const ad of await impl().open(null)) {
if (ad.expires > now) continue;
const a = await accounts.byEmail(ad.adoptee);
if (!a) { await impl().setStatus(ad.id, 'expired'); continue; }
if (a.address || a.memberId) { await impl().setStatus(ad.id, 'done'); done++; continue; }
if (await impl().countFor(ad.adoptee) >= MAX_ADOPTIONS) { await impl().setStatus(ad.id, 'expired'); kept++; continue; }
await accounts.setSponsorRef(ad.adoptee, '');
await impl().setStatus(ad.id, 'expired'); back++;
}
return { done, back, kept };
}
// pay-it-forward gift record: the sponsor already sent POL wallet-to-wallet; we
// only log the hash and tell the recipient. Recipient must be in the giver's line.
async function recordGift(fromEmail, toEmail, tx, pol) {
const f = String(fromEmail || '').toLowerCase(), t = String(toEmail || '').toLowerCase();
const giver = await accounts.byEmail(f), to = await accounts.byEmail(t);
if (!giver || !to) return { error: 'No such member.' };
const toks = [giver.code, giver.username, giver.memberId ? String(giver.memberId) : null].filter(Boolean).map(String);
const adopted = (await impl().open(f)).some(ad => ad.adoptee === t);
if (!toks.includes(String(to.sponsorRef || '')) && !adopted) return { error: 'That member is not in your line.' };
if (!/^0x[0-9a-fA-F]{64}$/.test(String(tx || ''))) return { error: 'Transaction hash missing.' };
const amount = Number(pol) || 0;
const row = { adoptee: t, adopter: f, ts: Date.now(), expires: Date.now(), status: 'gift', note: 'PIF ' + amount + ' POL ' + tx };
await impl().add(row);
const cc = chain.getConfig ? chain.getConfig() : {};
const link = ((cc.explorer || 'https://polygonscan.com').replace(/\/+$/, '')) + '/tx/' + tx;
const text = nameOf(giver) + ' just sent ' + amount + ' POL to your wallet so you can buy your first package. It is already there: open Buy packages when you are ready. Proof: ' + link;
try { await messages.sendChat(giver.memberId || 0, f, t, text); } catch (e) {}
if (mailer && mailer.hasKey()) { try { await mailer.send(t, nameOf(giver) + ' sent you POL for your first InstantAdPay package', text + '\n\n' + siteUrl + '/my#buy'); } catch (e) {} }
return { ok: true };
}
async function adminView() {
const recent = await impl().recent(200);
const names = {};
for (const ad of recent) for (const em of [ad.adoptee, ad.adopter]) if (!(em in names)) { const a = await accounts.byEmail(em); names[em] = a ? nameOf(a) : em; }
return { waiting: await waiting(), adoptions: recent.map(ad => Object.assign({}, ad, { adopteeName: names[ad.adoptee], adopterName: names[ad.adopter] })), cap: CAP_OPEN, ttlDays: TTL_MS / 86400000 };
}
module.exports = { init, view, adopt, release, sweep, adminView, waiting, hasOwnBuy, recordGift, CAP_OPEN, TTL_MS };