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:
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -76,6 +76,8 @@ async function coachView(email) {
|
||||
// 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.
|
||||
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));
|
||||
}
|
||||
// most actionable first: stalled lowest rung, then quiet days
|
||||
|
||||
@@ -139,6 +139,13 @@ async function bootstrap() {
|
||||
) 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 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 (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
owner_email VARCHAR(190) NOT NULL,
|
||||
|
||||
+7
-1
@@ -226,6 +226,12 @@
|
||||
</div>
|
||||
|
||||
<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-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>
|
||||
@@ -310,6 +316,6 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</html>
|
||||
|
||||
@@ -267,7 +267,16 @@
|
||||
|
||||
// ── members ──
|
||||
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() {
|
||||
loadTank();
|
||||
const r = await api('/api/admin/members');
|
||||
allMembers = r.members || [];
|
||||
drawMembers();
|
||||
|
||||
+56
-1
@@ -1011,7 +1011,54 @@
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
loadTank();
|
||||
try {
|
||||
const r = await (await fetch('/api/my/coach')).json();
|
||||
const el = $('coachList'); if (!el || r.error) return;
|
||||
@@ -1022,11 +1069,19 @@
|
||||
+ '<span class="em">' + esc(x.label) + ' → ' + esc(x.next) + '</span>'
|
||||
+ '<span class="id">rung ' + x.rung + '/6</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 () => {
|
||||
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(); }
|
||||
}));
|
||||
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) {}
|
||||
}
|
||||
// schedule chips on the campaign table (local time) + by-hour view bars
|
||||
|
||||
+17
-1
@@ -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) {
|
||||
for (let i = 0; i < 90; i++) {
|
||||
try {
|
||||
@@ -264,5 +280,5 @@ window.IAPWallet = (function () {
|
||||
return BigInt(h);
|
||||
}
|
||||
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
@@ -293,6 +293,13 @@
|
||||
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>
|
||||
<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">
|
||||
<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>
|
||||
@@ -890,9 +897,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<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/my.js?v=20260911c"></script>
|
||||
<script src="/assets/my.js?v=20260911e"></script>
|
||||
<script src="/assets/chat.js?v=20260907l"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 */ }
|
||||
const chatbot = require('./chatbot');
|
||||
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 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
|
||||
// follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot)
|
||||
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 });
|
||||
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);
|
||||
@@ -980,6 +983,37 @@ const server = http.createServer(async (req, res) => {
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
// -- 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') {
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user