diff --git a/accounts.js b/accounts.js index 4ceba60..465c108 100644 --- a/accounts.js +++ b/accounts.js @@ -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/ 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 }; diff --git a/ads.js b/ads.js index 45d6b3a..c2cac93 100644 --- a/ads.js +++ b/ads.js @@ -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(); } diff --git a/db.js b/db.js index edcbbb2..a88c4c4 100644 --- a/db.js +++ b/db.js @@ -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, diff --git a/public/assets/common.js b/public/assets/common.js index d32a0ec..bf61149 100644 --- a/public/assets/common.js +++ b/public/assets/common.js @@ -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 - ? '' + me.address.slice(0, 6) + 'โ€ฆ' + me.address.slice(-4) + '' - : (me.email ? String(me.email).replace(/[&<>]/g, '') : 'signed in'); + // identity order: username, then email, then wallet + const who = me.username + ? '@' + String(me.username).replace(/[&<>]/g, '') + '' + : (me.email ? String(me.email).replace(/[&<>]/g, '') + : (me.address ? '' + me.address.slice(0, 6) + 'โ€ฆ' + me.address.slice(-4) + '' : 'signed in')); el.innerHTML = (me.memberId ? 'member #' + me.memberId + ' ' : '') + who; } else { el.innerHTML = 'Sign in'; @@ -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; diff --git a/public/assets/my.js b/public/assets/my.js index 6bd9dfd..606fb64 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -45,7 +45,7 @@ if (t) t.remove(); t = document.createElement('table'); t.className = 'roster'; - t.innerHTML = d.referrals.map(r => '' + r.email + '' + t.innerHTML = d.referrals.map(r => '' + String(r.name || r.email || '').replace(/[&<>]/g, '') + '' + '' + new Date(r.joined).toLocaleDateString() + '' + '' + r.status + '').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') + '
Wallet: ' + + (me.address ? '' + me.address.slice(0, 10) + 'โ€ฆ' + me.address.slice(-6) + '' : 'not linked yet') + + '
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() { diff --git a/public/assets/site.css b/public/assets/site.css index 083fe1d..e77ea42 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -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} diff --git a/public/contract.html b/public/contract.html index ff452d9..2885673 100644 --- a/public/contract.html +++ b/public/contract.html @@ -5,7 +5,7 @@ The contract | InstantAdPay - +
@@ -129,8 +129,8 @@
Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.
- - - + + + diff --git a/public/index.html b/public/index.html index 43f270e..2090ea3 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ InstantAdPay: advertise and earn, locked in code - + @@ -405,9 +405,9 @@ - - - - + + + + diff --git a/public/ledger.html b/public/ledger.html index 19a36a6..130a977 100644 --- a/public/ledger.html +++ b/public/ledger.html @@ -5,7 +5,7 @@ Live ledger | InstantAdPay - +
@@ -25,8 +25,8 @@
InstantAdPay ยท how it works ยท contract source โ†—
- - - + + + diff --git a/public/my.html b/public/my.html index 7e390b7..35ff4b1 100644 --- a/public/my.html +++ b/public/my.html @@ -4,7 +4,7 @@ Member area | InstantAdPay - + @@ -59,7 +59,9 @@ + + + + + +