Member identity, promo tools, back-office color, seeded inventory

Real people, not numbers: usernames (unique, profile pane to set them),
shown across the ledger, activity, rosters, and the sidebar chip; vanity
invite links (/join/<username>) with late chain binding intact. Promo tools
pane ships Branded Voice share posts and an email swipe personalized with
each member's link. The earn viewer excludes a member's own campaigns, so
nobody earns from their own spend. Back office gains the cyan/violet/amber
accent family over the green base. Three house campaigns seeded so every
surface shows live inventory. Assets v=20260905i.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-05 06:28:17 -05:00
parent 28ed925443
commit e22021c266
11 changed files with 257 additions and 53 deletions
+60 -2
View File
@@ -33,7 +33,10 @@ function newCode(taken) {
return c;
}
const pub = a => a ? { email: a.email, sponsorRef: a.sponsorRef || '', code: a.code || null,
username: a.username || null, memberId: a.memberId || 0,
address: a.address || null, created: a.created } : null;
const USER_RE = /^[a-zA-Z0-9_]{3,20}$/;
const normUser = u => String(u || '').trim().toLowerCase();
// ---- JSON fallback ----
const J = {
@@ -82,6 +85,29 @@ const J = {
async byEmail(e) { return pub(this.db.byEmail[e]); },
async byAddress(a) { const e = this.db.byAddress[a]; return e ? pub(this.db.byEmail[e]) : null; },
async byCode(c) { const e = this.db.byCode[c]; return e ? pub(this.db.byEmail[e]) : null; },
async byUsername(u) {
for (const a of Object.values(this.db.byEmail)) if (a.username === u) return pub(a);
return null;
},
async setUsername(e, u) {
const acct = this.db.byEmail[e];
if (!acct) return { error: 'No such account.' };
for (const a of Object.values(this.db.byEmail)) if (a.username === u && a.email !== e)
return { error: 'That username is taken. Try another.' };
acct.username = u;
this.save();
return { ok: true, account: pub(acct) };
},
async setMemberId(e, id) {
const acct = this.db.byEmail[e];
if (acct && acct.memberId !== id) { acct.memberId = id; this.save(); }
},
async namesForMembers(ids) {
const out = {};
for (const a of Object.values(this.db.byEmail))
if (a.username && a.memberId && ids.includes(a.memberId)) out[a.memberId] = a.username;
return out;
},
async listByReferrer(refs) {
const set = new Set(refs.filter(Boolean).map(String));
return Object.values(this.db.byEmail)
@@ -104,7 +130,8 @@ const J = {
};
// ---- MySQL mode ----
const rowPub = r => r ? pub({ email: r.email, sponsorRef: r.sponsor_ref, code: r.code, address: r.address, created: Number(r.created) }) : null;
const rowPub = r => r ? pub({ email: r.email, sponsorRef: r.sponsor_ref, code: r.code,
username: r.username, memberId: r.member_id || 0, address: r.address, created: Number(r.created) }) : null;
const D = {
async signup(e, password, ref) {
const code = newCode();
@@ -140,6 +167,24 @@ const D = {
async byEmail(e) { const r = await db.q('SELECT * FROM accounts WHERE email=?', [e]); return rowPub(r[0]); },
async byAddress(a) { const r = await db.q('SELECT * FROM accounts WHERE address=?', [a]); return rowPub(r[0]); },
async byCode(c) { const r = await db.q('SELECT * FROM accounts WHERE code=?', [c]); return rowPub(r[0]); },
async byUsername(u) { const r = await db.q('SELECT * FROM accounts WHERE username=?', [u]); return rowPub(r[0]); },
async setUsername(e, u) {
try { await db.q('UPDATE accounts SET username=? WHERE email=?', [u, e]); }
catch (err) {
if (err.code === 'ER_DUP_ENTRY') return { error: 'That username is taken. Try another.' };
throw err;
}
return { ok: true, account: await this.byEmail(e) };
},
async setMemberId(e, id) { await db.q('UPDATE accounts SET member_id=? WHERE email=? AND (member_id IS NULL OR member_id<>?)', [id, e, id]); },
async namesForMembers(ids) {
if (!ids.length) return {};
const rows = await db.q('SELECT member_id, username FROM accounts WHERE username IS NOT NULL AND member_id IN ('
+ ids.map(() => '?').join(',') + ')', ids);
const out = {};
for (const r of rows) out[r.member_id] = r.username;
return out;
},
async listByReferrer(refs) {
const clean = refs.filter(Boolean).map(String);
if (!clean.length) return [];
@@ -180,6 +225,18 @@ async function ensure(email, sponsorRef) {
async function byEmail(email) { return impl().byEmail(normEmail(email)); }
async function byAddress(address) { return impl().byAddress(normAddr(address)); }
async function byCode(code) { return impl().byCode(String(code || '').toLowerCase()); }
async function byUsername(u) {
const n = normUser(u);
return USER_RE.test(n) ? impl().byUsername(n) : null;
}
async function setUsername(email, username) {
const n = normUser(username);
if (!USER_RE.test(n)) return { error: 'Usernames are 3 to 20 letters, numbers, or underscores.' };
if (/^\d+$/.test(n)) return { error: 'Usernames need at least one letter.' }; // keep /join/<number> unambiguous
return impl().setUsername(normEmail(email), n);
}
async function setMemberId(email, id) { return impl().setMemberId(normEmail(email), Number(id) || 0); }
async function namesForMembers(ids) { return impl().namesForMembers([...new Set(ids)].filter(n => n > 0)); }
async function listByReferrer(refs) { return impl().listByReferrer(refs || []); }
async function linkWallet(email, address) {
const a = normAddr(address);
@@ -188,4 +245,5 @@ async function linkWallet(email, address) {
}
async function count() { return impl().count(); }
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, listByReferrer, linkWallet, count };
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername,
setUsername, setMemberId, namesForMembers, listByReferrer, linkWallet, count };
+7 -5
View File
@@ -102,9 +102,10 @@ const J = {
this.save();
return { ok: true, campaign: pubC(c) };
},
async serve(type) {
async serve(type, opts) {
const r = rates();
const pool = this.db.campaigns.filter(c => c.type === type && c.status === 'active');
const ex = opts && opts.excludeEmail;
const pool = this.db.campaigns.filter(c => c.type === type && c.status === 'active' && (!ex || c.owner !== ex));
if (!pool.length) return null;
const c = pool[Math.floor(Math.random() * pool.length)];
c.imps += 1;
@@ -194,9 +195,10 @@ const D = {
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [Number(id)]);
return { ok: true, campaign: pubC(rowC(rows[0])) };
},
async serve(type) {
async serve(type, opts) {
const r = rates();
const rows = await db.q('SELECT * FROM campaigns WHERE type=? AND status=\'active\' ORDER BY RAND() LIMIT 1', [type]);
const ex = (opts && opts.excludeEmail) || '';
const rows = await db.q('SELECT * FROM campaigns WHERE type=? AND status=\'active\' AND owner_email<>? ORDER BY RAND() LIMIT 1', [type, ex]);
if (!rows.length) return null;
const c = rowC(rows[0]);
await db.q('UPDATE campaigns SET imps=imps+1, batch_imps=batch_imps+1 WHERE id=?', [c.id]);
@@ -411,7 +413,7 @@ async function setStatus(owner, id, status) {
if (!['active', 'paused'].includes(status)) return { error: 'Bad status.' };
return impl().setStatus(owner, id, status);
}
async function serve(type) { return TYPES.includes(type) ? impl().serve(type) : null; }
async function serve(type, opts) { return TYPES.includes(type) ? impl().serve(type, opts) : null; }
async function click(id) { return impl().click(id); }
async function dailySweep() { return impl().dailySweep(); }
async function pendingBurns() { return impl().pendingBurns(); }
+9
View File
@@ -70,6 +70,15 @@ async function bootstrap() {
granted_welcome TINYINT NOT NULL DEFAULT 0,
updated BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
// additive columns (MySQL 8 has no IF NOT EXISTS for columns)
const alterSafe = async sql => {
try { await q(sql); }
catch (e) { if (!['ER_DUP_FIELDNAME', 'ER_DUP_KEYNAME'].includes(e.code)) throw e; }
};
await alterSafe('ALTER TABLE accounts ADD COLUMN username VARCHAR(30) NULL');
await alterSafe('ALTER TABLE accounts ADD UNIQUE KEY uq_username (username)');
await alterSafe('ALTER TABLE accounts ADD COLUMN member_id INT NULL');
await alterSafe('ALTER TABLE accounts ADD KEY idx_member (member_id)');
await q(`CREATE TABLE IF NOT EXISTS daily_views (
email VARCHAR(190) NOT NULL,
day CHAR(10) NOT NULL,
+16 -11
View File
@@ -52,10 +52,11 @@ window.IAP = (function () {
const el = $('navWallet');
if (!el) return;
if (me.signedIn) {
// email-only members have no wallet address yet
const who = me.address
? '<span class="mono">' + me.address.slice(0, 6) + '…' + me.address.slice(-4) + '</span>'
: (me.email ? String(me.email).replace(/[&<>]/g, '') : 'signed in');
// identity order: username, then email, then wallet
const who = me.username
? '<b>@' + String(me.username).replace(/[&<>]/g, '') + '</b>'
: (me.email ? String(me.email).replace(/[&<>]/g, '')
: (me.address ? '<span class="mono">' + me.address.slice(0, 6) + '…' + me.address.slice(-4) + '</span>' : 'signed in'));
el.innerHTML = (me.memberId ? '<span class="badge">member #' + me.memberId + '</span> ' : '') + who;
} else {
el.innerHTML = '<a href="/my">Sign in</a>';
@@ -65,17 +66,21 @@ window.IAP = (function () {
}
function describeEvent(ev, c) {
const pol = w => fmtPol(w) + ' POL';
// real people, not numbers: use usernames when the site knows them
const nm = id => (ev.names && ev.names[id])
? String(ev.names[id]).replace(/[&<>]/g, '')
: 'member #' + id;
switch (ev.type) {
case 'Purchase': return '🧾 member #' + ev.buyerId + ' bought package #' + ev.productId
case 'Purchase': return '🧾 ' + nm(ev.buyerId) + ' bought package #' + ev.productId
+ ' (' + fmtUsd(ev.priceCents) + ') for ' + pol(ev.paidWei) + ' → +' + ev.creditAmount.toLocaleString() + ' credits';
case 'TierPaid': return '💸 level ' + ev.tier + ' payout → member #' + ev.recipientId + ': ' + pol(ev.amountWei)
case 'TierPaid': return '💸 level ' + ev.tier + ' payout → ' + nm(ev.recipientId) + ': ' + pol(ev.amountWei)
+ (ev.hops ? ' (passed up ' + ev.hops + ')' : '');
case 'PassedUp': return '↷ level ' + ev.tier + ' passed over #' + ev.skippedId + ' (' + ev.reason + ')';
case 'PassedUp': return '↷ level ' + ev.tier + ' passed over ' + nm(ev.skippedId) + ' (' + ev.reason + ')';
case 'AdminPaid': return '🏛 platform fee settled: ' + pol(ev.amountWei);
case 'BuyerCounted': return '⭐ member #' + ev.sponsorId + ' now has ' + ev.newCount + ' qualifying buyer(s)';
case 'MemberActivated': return '👤 member #' + ev.id + ' activated a payout wallet';
case 'AwardPaid': return '🎁 award: ' + pol(ev.amountWei) + ' → member #' + ev.toId;
case 'CreditsConsumed': return '📣 member #' + ev.memberId + ' ran ads: −' + ev.amount.toLocaleString() + ' credits';
case 'BuyerCounted': return '⭐ ' + nm(ev.sponsorId) + ' now has ' + ev.newCount + ' qualifying buyer(s)';
case 'MemberActivated': return '👤 ' + nm(ev.id) + ' activated a payout wallet';
case 'AwardPaid': return '🎁 award: ' + pol(ev.amountWei) + ' → ' + nm(ev.toId);
case 'CreditsConsumed': return '📣 ' + nm(ev.memberId) + ' ran ads: −' + ev.amount.toLocaleString() + ' credits';
case 'PriceCached': return '🔮 oracle price refreshed';
case 'FallbackPriceUsed': return '🔮 cached price bridged an oracle gap';
default: return '· ' + ev.type;
+57 -8
View File
@@ -45,7 +45,7 @@
if (t) t.remove();
t = document.createElement('table');
t.className = 'roster';
t.innerHTML = d.referrals.map(r => '<tr><td>' + r.email + '</td>'
t.innerHTML = d.referrals.map(r => '<tr><td>' + String(r.name || r.email || '').replace(/[&<>]/g, '') + '</td>'
+ '<td>' + new Date(r.joined).toLocaleDateString() + '</td>'
+ '<td><span class="badge' + (r.status === 'joined free' ? ' amber' : '') + '">' + r.status + '</span></td></tr>').join('');
wrap.appendChild(t);
@@ -72,8 +72,9 @@
rows.slice(0, 6).forEach(r => ov.appendChild(r));
}
} catch (e) {}
// ready-to-send share message
const link = location.origin + '/join/' + (d.refCode || d.memberId || '');
// ready-to-send share message + promo tools, personalized
const link = location.origin + '/join/' + (d.username || d.refCode || d.memberId || '');
fillPromo(link);
if (d.refCode || d.memberId) {
const pitch = 'I found an advertising site that pays referrals instantly to your own wallet. '
+ 'No withdrawals, no waiting, and every payment is public on a blockchain ledger you can check yourself. '
@@ -90,9 +91,9 @@
}
// ── back-office menu: hash-routed panes ───────────────
const PANES = ['overview', 'line', 'buy', 'campaigns', 'earn', 'earnings', 'wallet'];
const PANES = ['overview', 'line', 'buy', 'campaigns', 'earn', 'earnings', 'promo', 'wallet', 'profile'];
const TITLES = { overview: 'Overview', line: 'My line', buy: 'Buy packages', campaigns: 'Campaigns',
earn: 'Earn credits', earnings: 'Earnings', wallet: 'Wallet & account' };
earn: 'Earn credits', earnings: 'Earnings', promo: 'Promo tools', wallet: 'Wallet & account', profile: 'Profile' };
function setPane(name) {
if (!PANES.includes(name)) name = 'overview';
for (const p of PANES) {
@@ -148,9 +149,15 @@
$('campaignCard').hidden = false;
loadCampaigns();
// the share link works from day one; codes resolve to your chain id later
if (me.refCode || me.memberId) {
$('inviteLine').textContent = location.origin + '/join/' + (me.refCode || me.memberId);
// profile pane state
$('pfCurrent').textContent = me.username ? 'Current username: @' + me.username : 'No username yet. Members see you as a number until you pick one.';
if (!$('pfUsername').value) $('pfUsername').value = me.username || '';
$('pfDetails').innerHTML = 'Email: ' + (me.email || 'none') + '<br>Wallet: '
+ (me.address ? '<span class="mono">' + me.address.slice(0, 10) + '…' + me.address.slice(-6) + '</span>' : 'not linked yet')
+ '<br>On-chain member: ' + (me.memberId ? '#' + me.memberId : 'not yet');
// the share link works from day one; usernames make it a vanity link
if (me.username || me.refCode || me.memberId) {
$('inviteLine').textContent = location.origin + '/join/' + (me.username || me.refCode || me.memberId);
$('copyInvite').hidden = false;
} else {
$('inviteLine').textContent = 'Sign in with your email to get your link.';
@@ -233,6 +240,48 @@
// defers the busy() lookup to click time (busy is declared below)
function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); }
// ── promo tools: Branded Voice copy, personalized with the member link ──
const PROMO_POSTS = [
'A membership site where money is handled by code, not people. Every purchase splits instantly to sponsor wallets on the Polygon blockchain. Nothing to withdraw. The money just lands in your wallet. Plus you earn ad credits for viewing ads while you\'re there. {{LINK}}',
'No withdrawal button. Think about that. A smart contract on Polygon splits every payment the second it hits. 50% to the sponsor. 20% to the next level. 10% to the next. Lands straight in your own wallet. No button to push. No waiting. Just money where it belongs. See how it works: {{LINK}}',
'Every payment is public on the blockchain. You can watch the ledger move in real time. Every split, every wallet, every transaction. Nothing hidden. Nothing you have to take on faith. That\'s the difference between a platform that asks for trust and one where trust isn\'t needed. See for yourself: {{LINK}}'
];
const PROMO_SWIPE = {
subject: 'Your Wallet Gets Paid Instantly..',
body: 'You know the usual drill. Someone buys on your link, you wait for a payout. Maybe days. Maybe an approval hold. Maybe a "your account is under review."\n\nInstantAdPay doesn\'t work like that.\n\nA smart contract on the Polygon blockchain handles every purchase the second it happens. 50% to the sponsor. 20% to the next level. 10% to the one after that. 20% to the platform. Each split lands directly in your own wallet. No withdrawal button. No "request payout." No approval queue.\n\nThe money just shows up.\n\nYou can watch every transaction on the public ledger. Real time. Anyone can verify it.\n\nFree to join. Packages from $5 to $250. No income promises. It\'s advertising, not investing.\n\n{{LINK}}'
};
function promoBlock(text) {
const div = document.createElement('div');
div.className = 'promo-block';
div.textContent = text;
const btn = document.createElement('button');
btn.className = 'btn small sec';
btn.textContent = 'Copy';
btn.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(text); IAP.status('Copied. Paste it anywhere.', 'ok'); }
catch (e) { IAP.status('Copy failed. Select the text instead.', 'bad'); }
});
div.appendChild(btn);
return div;
}
function fillPromo(link) {
const posts = $('promoPosts');
if (!posts || posts.dataset.filled === link) return;
posts.dataset.filled = link;
posts.innerHTML = '';
for (const p of PROMO_POSTS) posts.appendChild(promoBlock(p.replace('{{LINK}}', link)));
const sw = $('promoSwipeWrap');
sw.innerHTML = '';
sw.appendChild(promoBlock('Subject: ' + PROMO_SWIPE.subject + '\n\n' + PROMO_SWIPE.body.replace('{{LINK}}', link)));
}
// ── profile ── (busy2 defers the busy lookup past its TDZ)
$('pfSaveBtn').addEventListener('click', busy2($('pfSaveBtn'), async () => {
const r = await api('/api/my/profile', { username: $('pfUsername').value });
IAP.status('You are @' + r.account.username + ' now.', 'ok');
await render();
}));
// ── in-dashboard package buying ──
const PKG = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' };
async function loadBuyTiles() {
+20
View File
@@ -9,6 +9,7 @@
--ink:#eef7f3; --muted:#8ba69c;
--mint:#43e8c3; --mint-hi:#8ffbe3; --mint-ink:#03211a;
--mint-soft:rgba(67,232,195,.09); --bad:#ff8f7d;
--cyan:#54ccff; --violet:#9d7dff; --amber:#ffb238;
--mono:"Consolas","JetBrains Mono",monospace;
--disp:"Sora","Segoe UI",system-ui,sans-serif;
--radius:18px;
@@ -257,6 +258,25 @@ input:focus,select:focus{border-color:var(--mint)}
#boBurger{display:block}
.bo-content{padding:18px}
}
/* back-office accent family: green leads, cyan/violet/amber season the cards */
.bo .stats .stat:nth-child(2) .n{color:var(--cyan)}
.bo .stats .stat:nth-child(2)::before{background:linear-gradient(90deg,transparent,var(--cyan),transparent)}
.bo .stats .stat:nth-child(3) .n{color:var(--violet)}
.bo .stats .stat:nth-child(3)::before{background:linear-gradient(90deg,transparent,var(--violet),transparent)}
.bo .stats .stat:nth-child(4) .n{color:var(--amber)}
.bo .stats .stat:nth-child(4)::before{background:linear-gradient(90deg,transparent,var(--amber),transparent)}
.bo .card h3::before{content:"";display:inline-block;width:9px;height:9px;border-radius:2.5px;
background:var(--mint);margin-right:10px;transform:rotate(45deg);vertical-align:1px}
#pane-line .card h3::before{background:var(--cyan)}
#pane-buy .card h3::before,#pane-campaigns .card h3::before{background:var(--amber)}
#pane-earn .card h3::before,#pane-earnings .card h3::before{background:var(--violet)}
#pane-promo .card h3::before{background:var(--cyan)}
#pane-wallet .card h3::before,#pane-profile .card h3::before{background:var(--mint)}
.bo .card{background:linear-gradient(165deg,rgba(24,36,31,.6),rgba(13,19,17,.66))}
#pane-line .qualbar,#nextCard{border-left:3px solid rgba(67,232,195,.4)}
.promo-block{background:rgba(4,8,7,.55);border:1px solid var(--line);border-radius:12px;
padding:14px 16px;margin:0 0 12px;font-size:13.5px;line-height:1.6;white-space:pre-wrap}
.promo-block .btn{margin-top:10px}
/* back-office polish: pane transitions, quick actions */
@media(prefers-reduced-motion:no-preference){
.pane:not([hidden]){animation:panein .25s ease}
+4 -4
View File
@@ -5,7 +5,7 @@
<title>The contract | InstantAdPay</title>
<meta name="description" content="Plain-language review of the InstantAdPay settlement contract: what it does, what nobody can change, what the operator can and cannot touch, and how to verify all of it yourself.">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905g">
<link rel="stylesheet" href="/assets/site.css?v=20260905i">
</head>
<body>
<div class="wrap">
@@ -129,8 +129,8 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
</footer>
</div>
<script src="/assets/common.js?v=20260905g"></script>
<script src="/assets/contract.js?v=20260905g"></script>
<script src="/assets/chat.js?v=20260905g"></script>
<script src="/assets/common.js?v=20260905i"></script>
<script src="/assets/contract.js?v=20260905i"></script>
<script src="/assets/chat.js?v=20260905i"></script>
</body>
</html>
+5 -5
View File
@@ -5,7 +5,7 @@
<title>InstantAdPay: advertise and earn, locked in code</title>
<meta name="description" content="Real ad packages with instant on-chain settlement. Every purchase pays the sponsor line in the same transaction, verifiable by anyone on the live ledger.">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905g">
<link rel="stylesheet" href="/assets/site.css?v=20260905i">
</head>
<body>
@@ -405,9 +405,9 @@
</div>
</section>
<script src="/assets/common.js?v=20260905g"></script>
<script src="/assets/wallet.js?v=20260905g"></script>
<script src="/assets/home.js?v=20260905g"></script>
<script src="/assets/chat.js?v=20260905g"></script>
<script src="/assets/common.js?v=20260905i"></script>
<script src="/assets/wallet.js?v=20260905i"></script>
<script src="/assets/home.js?v=20260905i"></script>
<script src="/assets/chat.js?v=20260905i"></script>
</body>
</html>
+4 -4
View File
@@ -5,7 +5,7 @@
<title>Live ledger | InstantAdPay</title>
<meta name="description" content="Every purchase, payout, and pass-up on InstantAdPay, streamed straight from the blockchain with a verify link on every line.">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905g">
<link rel="stylesheet" href="/assets/site.css?v=20260905i">
</head>
<body>
<div class="wrap">
@@ -25,8 +25,8 @@
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
</footer>
</div>
<script src="/assets/common.js?v=20260905g"></script>
<script src="/assets/ledger.js?v=20260905g"></script>
<script src="/assets/chat.js?v=20260905g"></script>
<script src="/assets/common.js?v=20260905i"></script>
<script src="/assets/ledger.js?v=20260905i"></script>
<script src="/assets/chat.js?v=20260905i"></script>
</body>
</html>
+41 -5
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Member area | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905g">
<link rel="stylesheet" href="/assets/site.css?v=20260905i">
</head>
<body class="bo-body">
@@ -59,7 +59,9 @@
<button data-pane="campaigns" type="button"><svg viewBox="0 0 24 24"><path d="M3 11l14-5v12L3 13v-2z"/><path d="M17 8a4 4 0 0 1 0 8M7 13v5a2 2 0 0 0 4 0v-3"/></svg>Campaigns</button>
<button data-pane="earn" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5v9M9 10c0-1.1 1.3-1.8 3-1.8s3 .7 3 1.8-1.3 1.6-3 1.8-3 .7-3 1.8 1.3 1.8 3 1.8 3-.7 3-1.8"/></svg>Earn credits</button>
<button data-pane="earnings" type="button"><svg viewBox="0 0 24 24"><path d="M4 17l5-5 4 3 7-8"/><path d="M14 7h6v6"/></svg>Earnings</button>
<button data-pane="promo" type="button"><svg viewBox="0 0 24 24"><path d="M7 10s5-1 9-5v14c-4-4-9-5-9-5H5a2 2 0 0 1-2-2v0a2 2 0 0 1 2-2h2z"/><path d="M8 15l1 5h2l-1-5"/></svg>Promo tools</button>
<button data-pane="wallet" type="button"><svg viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><circle cx="16.5" cy="14.5" r="1.4"/></svg>Wallet</button>
<button data-pane="profile" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="8.5" r="3.6"/><path d="M4.5 20c1.6-3.6 4.2-5.2 7.5-5.2s5.9 1.6 7.5 5.2"/></svg>Profile</button>
</nav>
<div class="bo-links" id="adSlotSide" hidden style="border-top:1px solid var(--line)"></div>
<div class="bo-links">
@@ -227,6 +229,40 @@
</div>
</div>
<div class="pane" id="pane-promo" hidden>
<div class="card">
<h3>Share-ready posts, personalized with your link</h3>
<p class="muted small">Copy, paste anywhere, done. Every post already carries your invite link,
so the credit is always yours.</p>
<div id="promoPosts"></div>
</div>
<div class="card">
<h3>Email swipe</h3>
<p class="muted small" id="promoSwipeWrap"></p>
</div>
<div class="card">
<h3>Banners</h3>
<p class="muted small">Branded InstantAdPay banner sets are in production. They will land here
sized for every standard placement, pre-tagged with your link.</p>
</div>
</div>
<div class="pane" id="pane-profile" hidden>
<div class="card">
<h3>Your profile</h3>
<p class="muted small">Your username is how other members see you: on the live ledger, in line
rosters, and on your personal invite link. Pick something you would put on a business card.</p>
<p><input id="pfUsername" placeholder="Username (3-20 letters, numbers, _)" style="width:100%;max-width:320px"></p>
<button class="btn" id="pfSaveBtn">Save username</button>
<p class="muted small" id="pfCurrent" style="margin-top:10px"></p>
</div>
<div class="card">
<h3>Account details</h3>
<p class="muted small" id="pfDetails">…</p>
<p class="muted small">More profile fields (avatar, bio, links) are on the roadmap.</p>
</div>
</div>
<div class="pane" id="pane-wallet" hidden>
<div class="grid c2">
<div class="card"><h3>Your account</h3>
@@ -253,9 +289,9 @@
</div>
</div>
<script src="/assets/common.js?v=20260905g"></script>
<script src="/assets/wallet.js?v=20260905g"></script>
<script src="/assets/my.js?v=20260905g"></script>
<script src="/assets/chat.js?v=20260905g"></script>
<script src="/assets/common.js?v=20260905i"></script>
<script src="/assets/wallet.js?v=20260905i"></script>
<script src="/assets/my.js?v=20260905i"></script>
<script src="/assets/chat.js?v=20260905i"></script>
</body>
</html>
+34 -9
View File
@@ -39,7 +39,7 @@ const emailCodes = new Map();
const earnTokens = new Map();
async function boot() {
await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode)
chain.init({ onEvent: ev => pushFeed(ev) });
chain.init({ onEvent: ev => attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)) });
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
accounts.init({ dataDir: DATA_DIR });
ads.init({ dataDir: DATA_DIR, chain });
@@ -101,6 +101,18 @@ function isAdmin(req) {
const h = req.headers.authorization || '';
return h === 'Bearer ' + ADMIN_PASSWORD;
}
// attach a memberId->username map to events so activity shows real people
async function attachNames(evts) {
try {
const ids = [];
for (const ev of evts)
for (const k of ['id', 'buyerId', 'recipientId', 'skippedId', 'sponsorId', 'newBuyerId', 'toId', 'memberId'])
if (ev[k]) ids.push(ev[k]);
const names = await accounts.namesForMembers(ids);
if (!Object.keys(names).length) return evts;
return evts.map(ev => Object.assign({}, ev, { names }));
} catch (e) { return evts; }
}
// A sponsor token is a numeric chain id or a site share code. Codes resolve
// to the referrer's CURRENT chain id, so activation any time before the
// referral's first purchase still locks the line to them.
@@ -108,7 +120,8 @@ async function resolveSponsorToken(tok) {
const t = String(tok || '').trim().toLowerCase();
if (!t) return 0;
if (/^\d+$/.test(t)) return Number(t);
const acct = await accounts.byCode(t);
let acct = await accounts.byCode(t);
if (!acct) acct = await accounts.byUsername(t); // vanity links: /join/<username>
if (!acct || !acct.address) return 0;
try { return await chain.memberIdByAccount(acct.address); } catch (e) { return 0; }
}
@@ -144,7 +157,7 @@ const server = http.createServer(async (req, res) => {
// -- join links: /join/<memberId or share code> — first-touch cookie.
// Codes resolve LATE (at buy time) to whatever chain id the referrer
// has by then, so free members refer from day one.
let m = /^\/join\/([A-Za-z0-9]{1,16})$/.exec(p);
let m = /^\/join\/([A-Za-z0-9_]{1,20})$/.exec(p);
if (m && req.method === 'GET') {
const tok = m[1].toLowerCase();
const cookies = parseCookies(req);
@@ -167,7 +180,7 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { products: await chain.catalog() });
}
if (p === '/api/feed' && req.method === 'GET') {
return json(res, 200, { events: chain.recentEvents(Number(u.searchParams.get('n')) || 100) });
return json(res, 200, { events: await attachNames(chain.recentEvents(Number(u.searchParams.get('n')) || 100)) });
}
if (p === '/api/feed/live' && req.method === 'GET') {
res.writeHead(200, baseHeaders({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' }));
@@ -297,8 +310,10 @@ const server = http.createServer(async (req, res) => {
const memberId = await auth.refreshMemberId(s);
const acct = (s.email && await accounts.byEmail(s.email)) || (s.address && await accounts.byAddress(s.address)) || null;
const sponsorId = await resolveSponsorToken((acct && acct.sponsorRef) || parseCookies(req)['iap.sponsor']);
if (memberId && acct && acct.memberId !== memberId) accounts.setMemberId(acct.email, memberId).catch(() => {});
const out = { signedIn: true, email: s.email || (acct && acct.email) || null,
address: s.address || (acct && acct.address) || null, memberId,
username: (acct && acct.username) || null,
refCode: (acct && acct.code) || null, sponsorId };
if (memberId) {
try {
@@ -316,8 +331,10 @@ const server = http.createServer(async (req, res) => {
if (!s) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s);
const acct = (s.email && await accounts.byEmail(s.email)) || (s.address && await accounts.byAddress(s.address)) || null;
if (memberId && acct && acct.memberId !== memberId) accounts.setMemberId(acct.email, memberId).catch(() => {});
const out = { memberId, email: s.email || (acct && acct.email) || null,
address: s.address || (acct && acct.address) || null,
username: (acct && acct.username) || null,
refCode: (acct && acct.code) || null, credits: 0, buyerCount: 0,
earnedWei: '0', earnCount: 0, referrals: [], welcomeCredits: 0 };
if (out.email) out.welcomeCredits = await ads.grantWelcome(out.email); // idempotent lazy grant
@@ -342,12 +359,19 @@ const server = http.createServer(async (req, res) => {
if (memberId) refs.push(String(memberId));
const joined = await accounts.listByReferrer(refs);
out.referrals = joined.map(r => ({
email: r.email.replace(/^(.).*(@.*)$/, '$1***$2'), // privacy mask
name: r.username || r.email.replace(/^(.).*(@.*)$/, '$1***$2'), // username, else privacy mask
joined: r.created,
status: r.address ? 'wallet linked' : 'joined free'
}));
return json(res, 200, out);
}
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.' });
const b = await readBody(req);
const r = await accounts.setUsername(s.email, b.username);
return json(res, r.error ? 400 : 200, r);
}
// -- earn credits by viewing ads (attention-gated daily claim)
if (p === '/api/my/earn' && req.method === 'GET') {
const s = await auth.fromRequest(req);
@@ -364,7 +388,8 @@ const server = http.createServer(async (req, res) => {
const status = await ads.viewStatus(s.email);
if (status.views >= status.target || status.claimed) return json(res, 200, { ad: null, status });
const type = String(u.searchParams.get('type') || 'banner');
const ad = await ads.serve(type === 'text' ? 'text' : 'banner');
// members never see (or earn from) their own campaigns in the viewer
const ad = await ads.serve(type === 'text' ? 'text' : 'banner', { excludeEmail: s.email });
if (!ad) return json(res, 200, { ad: null, status });
const token = crypto.randomBytes(16).toString('hex');
earnTokens.set(s.email, { token, ts: Date.now() });
@@ -397,9 +422,9 @@ const server = http.createServer(async (req, res) => {
const evs = chain.recentEvents(600);
return json(res, 200, {
memberId: id,
earnings: evs.filter(e => (e.type === 'TierPaid' && e.recipientId === id) || (e.type === 'AwardPaid' && e.toId === id)),
purchases: evs.filter(e => e.type === 'Purchase' && e.buyerId === id),
referrals: evs.filter(e => (e.type === 'MemberActivated' && e.sponsorId === id) || (e.type === 'BuyerCounted' && e.sponsorId === id))
earnings: await attachNames(evs.filter(e => (e.type === 'TierPaid' && e.recipientId === id) || (e.type === 'AwardPaid' && e.toId === id))),
purchases: await attachNames(evs.filter(e => e.type === 'Purchase' && e.buyerId === id)),
referrals: await attachNames(evs.filter(e => (e.type === 'MemberActivated' && e.sponsorId === id) || (e.type === 'BuyerCounted' && e.sponsorId === id)))
});
}