Sponsor to downline messaging: lineage view, daily broadcast (email+inbox), login modal

- Downline resolver (3 levels); email visible for directs only, username+ID deeper
- Broadcast composer (rich editor) to directs or full downline, 1/day cap, sends email + on-site inbox
- Upline messages inbox in My line; unmissable sign-in modal for unread, ack marks read
- messages.js dual-mode store; sponsor_messages table; sanitizeRich reused

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-06 08:39:06 -05:00
parent 289538dbeb
commit 7701a29169
14 changed files with 344 additions and 27 deletions
+25 -1
View File
@@ -261,6 +261,30 @@ async function setUsername(email, username) {
async function setMemberId(email, id) { return impl().setMemberId(normEmail(email), Number(id) || 0); } 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 namesForMembers(ids) { return impl().namesForMembers([...new Set(ids)].filter(n => n > 0)); }
async function listByReferrer(refs) { return impl().listByReferrer(refs || []); } async function listByReferrer(refs) { return impl().listByReferrer(refs || []); }
// the tokens a member could have been joined under (code, username, or numeric id)
function refTokens(a) {
return [a.code, a.username, a.memberId ? String(a.memberId) : null].filter(Boolean).map(String);
}
// walk the downline breadth-first to `depth` levels. Returns
// [{ level, members:[pub...] }]; emails are included on pub but the CALLER
// decides who may see them (directs only, per product rule).
async function downline(email, depth = 3) {
const root = await byEmail(email);
if (!root) return [];
const seen = new Set([String(root.email)]);
const levels = [];
let frontier = [root];
for (let lvl = 1; lvl <= depth; lvl++) {
const toks = [...new Set(frontier.flatMap(refTokens))];
if (!toks.length) break;
const kids = (await listByReferrer(toks)).filter(k => !seen.has(String(k.email)));
if (!kids.length) break;
kids.forEach(k => seen.add(String(k.email)));
levels.push({ level: lvl, members: kids });
frontier = kids;
}
return levels;
}
async function linkWallet(email, address) { async function linkWallet(email, address) {
const a = normAddr(address); const a = normAddr(address);
if (!/^0x[0-9a-f]{40}$/.test(a)) return { error: 'Bad wallet address.' }; if (!/^0x[0-9a-f]{40}$/.test(a)) return { error: 'Bad wallet address.' };
@@ -269,6 +293,6 @@ async function linkWallet(email, address) {
async function count() { return impl().count(); } async function count() { return impl().count(); }
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername, module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername,
setUsername, setMemberId, namesForMembers, listByReferrer, linkWallet, count, setUsername, setMemberId, namesForMembers, listByReferrer, downline, linkWallet, count,
setLineBanner: (e, b, t) => impl().setLineBanner(String(e || '').toLowerCase(), b, t), setLineBanner: (e, b, t) => impl().setLineBanner(String(e || '').toLowerCase(), b, t),
byMemberId: id => impl().byMemberId(id) }; byMemberId: id => impl().byMemberId(id) };
BIN
View File
Binary file not shown.
+11
View File
@@ -110,6 +110,17 @@ async function bootstrap() {
await alterSafe('ALTER TABLE campaigns ADD COLUMN height INT NULL'); // banner size (IAB) → also NAS height await alterSafe('ALTER TABLE campaigns ADD COLUMN height INT NULL'); // banner size (IAB) → also NAS height
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_ad_id INT NULL'); // syndicated NAS sponsorads.ID await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_ad_id INT NULL'); // syndicated NAS sponsorads.ID
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_served INT NOT NULL DEFAULT 0'); // NAS impressions already reconciled into spend await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_served INT NOT NULL DEFAULT 0'); // NAS impressions already reconciled into spend
await q(`CREATE TABLE IF NOT EXISTS sponsor_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
from_member INT NOT NULL,
from_email VARCHAR(190) NOT NULL,
to_email VARCHAR(190) NOT NULL,
subject VARCHAR(160) NOT NULL,
body TEXT NULL,
sent BIGINT NOT NULL,
read_ts BIGINT NULL,
INDEX (to_email, read_ts), INDEX (from_email, sent)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS burns ( await q(`CREATE TABLE IF NOT EXISTS burns (
id VARCHAR(32) PRIMARY KEY, id VARCHAR(32) PRIMARY KEY,
member_id INT NOT NULL, member_id INT NOT NULL,
+96
View File
@@ -0,0 +1,96 @@
// Sponsor → downline messages: team comms delivered to members' on-site inbox
// (and by email). Distinct from solo ADS (which are paid and earn credits).
// Dual-mode: MySQL when DATABASE_URL is set, else a JSON file in the volume.
const fs = require('fs');
const path = require('path');
const db = require('./db');
let DATA_DIR = null;
const J = {
db: null,
FILE: () => path.join(DATA_DIR, 'sponsor-messages.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = { nextId: 1, items: [] }; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
};
function init(opts) { DATA_DIR = opts.dataDir; }
const DAY = 86400000;
// has this sponsor already broadcast within the last 24h? (1/day cap)
async function lastBroadcastAt(fromEmail) {
const e = String(fromEmail || '').toLowerCase();
if (db.enabled()) {
const r = await db.q('SELECT MAX(sent) m FROM sponsor_messages WHERE from_email=?', [e]);
return r.length && r[0].m ? Number(r[0].m) : 0;
}
if (!J.db) J.load();
const mine = J.db.items.filter(i => i.fromEmail === e).map(i => i.sent);
return mine.length ? Math.max(...mine) : 0;
}
// deliver one message to many recipients (already-resolved emails). Returns count.
async function deliver(fromMember, fromEmail, recipients, subject, body) {
const now = Date.now();
let n = 0;
if (db.enabled()) {
for (const to of recipients) {
await db.q('INSERT INTO sponsor_messages (from_member,from_email,to_email,subject,body,sent) VALUES (?,?,?,?,?,?)',
[fromMember || 0, fromEmail, to, subject, body, now]);
n++;
}
} else {
if (!J.db) J.load();
for (const to of recipients) {
J.db.items.push({ id: J.db.nextId++, fromMember: fromMember || 0, fromEmail, toEmail: to, subject, body, sent: now, readTs: 0 });
n++;
}
J.save();
}
return n;
}
async function inbox(email) {
const e = String(email || '').toLowerCase();
if (db.enabled()) {
const rows = await db.q(`SELECT id, from_member, subject, body, sent, read_ts FROM sponsor_messages
WHERE to_email=? ORDER BY sent DESC LIMIT 100`, [e]);
return rows.map(r => ({ id: r.id, fromMember: r.from_member, subject: r.subject, body: r.body,
sent: Number(r.sent), read: !!r.read_ts }));
}
if (!J.db) J.load();
return J.db.items.filter(i => i.toEmail === e).sort((a, b) => b.sent - a.sent).slice(0, 100)
.map(i => ({ id: i.id, fromMember: i.fromMember, subject: i.subject, body: i.body, sent: i.sent, read: !!i.readTs }));
}
async function unreadCount(email) {
const e = String(email || '').toLowerCase();
if (db.enabled()) {
const r = await db.q('SELECT COUNT(*) n FROM sponsor_messages WHERE to_email=? AND read_ts IS NULL', [e]);
return r[0].n;
}
if (!J.db) J.load();
return J.db.items.filter(i => i.toEmail === e && !i.readTs).length;
}
// the newest unread message (for the login modal)
async function newestUnread(email) {
const e = String(email || '').toLowerCase();
if (db.enabled()) {
const rows = await db.q(`SELECT id, from_member, subject, body, sent FROM sponsor_messages
WHERE to_email=? AND read_ts IS NULL ORDER BY sent DESC LIMIT 1`, [e]);
if (!rows.length) return null;
const r = rows[0];
return { id: r.id, fromMember: r.from_member, subject: r.subject, body: r.body, sent: Number(r.sent) };
}
if (!J.db) J.load();
const u = J.db.items.filter(i => i.toEmail === e && !i.readTs).sort((a, b) => b.sent - a.sent)[0];
return u ? { id: u.id, fromMember: u.fromMember, subject: u.subject, body: u.body, sent: u.sent } : null;
}
async function markRead(email, id) {
const e = String(email || '').toLowerCase();
if (db.enabled()) {
await db.q('UPDATE sponsor_messages SET read_ts=? WHERE id=? AND to_email=? AND read_ts IS NULL', [Date.now(), Number(id), e]);
return { ok: true };
}
if (!J.db) J.load();
const i = J.db.items.find(x => x.id === Number(id) && x.toEmail === e);
if (i && !i.readTs) { i.readTs = Date.now(); J.save(); }
return { ok: true };
}
module.exports = { init, lastBroadcastAt, deliver, inbox, unreadCount, newestUnread, markRead };
+61
View File
@@ -146,6 +146,7 @@
const tc = $('dbTeamChip'), wk = (d.referrals || []).filter(r => Date.now() - new Date(r.joined) < 6048e5).length; const tc = $('dbTeamChip'), wk = (d.referrals || []).filter(r => Date.now() - new Date(r.joined) < 6048e5).length;
if (tc && wk) { tc.hidden = false; tc.textContent = '+' + wk + ' this week'; } if (tc && wk) { tc.hidden = false; tc.textContent = '+' + wk + ' this week'; }
setInboxBadge(d.inboxUnread || 0); setInboxBadge(d.inboxUnread || 0);
if (d.sponsorMsg) showSponsorModal(d.sponsorMsg);
loadCharts(d); loadCharts(d);
$('nextMove').textContent = nextMove(d); $('nextMove').textContent = nextMove(d);
renderSteps(d); renderSteps(d);
@@ -228,6 +229,7 @@
if ($('boTitle')) $('boTitle').textContent = TITLES[name]; if ($('boTitle')) $('boTitle').textContent = TITLES[name];
if (name === 'earn') setEarnSub(earnSub); // refresh whichever sub-tab is active if (name === 'earn') setEarnSub(earnSub); // refresh whichever sub-tab is active
if (name === 'profile') loadLineBanner(); if (name === 'profile') loadLineBanner();
if (name === 'line') { loadLineage(); loadUplineMessages(); }
document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer
if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name); if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name);
} }
@@ -537,6 +539,65 @@
} }
$('vidStartBtn').addEventListener('click', () => loadVideoAd()); $('vidStartBtn').addEventListener('click', () => loadVideoAd());
// ── unmissable sponsor-message modal on sign-in ──
function showSponsorModal(msg) {
if (!$('msgModal')) return;
$('mmFrom').textContent = 'A message from ' + (msg.fromName || 'your sponsor');
$('mmSubject').textContent = msg.subject || '';
$('mmBody').innerHTML = msg.body || ''; // server-sanitized
$('msgModal').hidden = false;
$('mmAck').onclick = async () => {
$('msgModal').hidden = true;
try { await fetch('/api/my/messages/' + msg.id + '/read', { method: 'POST' }); } catch (e) {}
};
}
// ── downline lineage + sponsor broadcast + upline messages ──
async function loadLineage() {
try {
const r = await (await fetch('/api/my/line')).json();
const el = $('lineageWrap');
if (r.error || !r.levels || !r.levels.every) return;
if (!r.levels.length || !r.levels.some(L => L.members.length)) {
el.innerHTML = '<p class="muted small">No one in your downline yet. Share your link and it fills in here.</p>';
return;
}
el.innerHTML = r.levels.map(L => !L.members.length ? '' :
'<div class="lin-lvl"><div class="cap">Level ' + L.level + ' · ' + L.members.length + (L.level === 1 ? ' direct' : '') + '</div>'
+ L.members.map(m => '<div class="lin-row"><span class="nm">' + esc(m.name) + '</span>'
+ (m.email ? '<span class="em">' + esc(m.email) + '</span>' : '<span class="id">#' + m.memberId + '</span>')
+ '<span class="id" style="margin-left:auto">' + new Date(m.joined).toLocaleDateString() + '</span></div>').join('')
+ '</div>').join('');
} catch (e) {}
}
async function loadUplineMessages() {
try {
const r = await (await fetch('/api/my/messages')).json();
if (r.error) return;
const card = $('upMsgCard'), list = $('upList');
if (!r.items || !r.items.length) { card.hidden = true; return; }
card.hidden = false;
list.innerHTML = r.items.map(i => '<div class="promo-block" style="margin-bottom:10px">'
+ '<div style="display:flex;justify-content:space-between;gap:10px"><b>' + esc(i.subject) + '</b>'
+ '<span class="small muted">' + esc(i.fromName) + ' · ' + new Date(i.sent).toLocaleDateString() + '</span></div>'
+ '<div class="ib-rich" style="margin-top:8px">' + (i.body || '') + '</div></div>').join('');
// opening the pane marks them read
for (const i of r.items) if (!i.read) fetch('/api/my/messages/' + i.id + '/read', { method: 'POST' }).catch(() => {});
} catch (e) {}
}
// broadcast composer editor (its own small rich editor, server sanitizes)
document.querySelectorAll('[data-bc]').forEach(b =>
b.addEventListener('click', () => { $('bcEd').focus(); document.execCommand(b.dataset.bc, false, null); }));
document.querySelectorAll('[data-bcblock]').forEach(b =>
b.addEventListener('click', () => { $('bcEd').focus(); document.execCommand('formatBlock', false, b.dataset.bcblock); }));
if ($('bcLinkBtn')) $('bcLinkBtn').addEventListener('click', () => { const u = prompt('Link URL (https://…)'); if (u) { $('bcEd').focus(); document.execCommand('createLink', false, u); } });
if ($('bcSendBtn')) $('bcSendBtn').addEventListener('click', busy2($('bcSendBtn'), async () => {
const r = await api('/api/my/broadcast', { scope: $('bcScope').value, subject: $('bcSubject').value, body: $('bcEd').innerHTML });
IAP.status('Broadcast sent to ' + r.sent + ' member' + (r.sent === 1 ? '' : 's') + '.', 'ok');
$('bcSubject').value = ''; $('bcEd').innerHTML = '';
$('bcHint').textContent = 'Sent. You can send your next broadcast in 24 hours.';
}));
// ── solo-ads inbox: list, read view, dwell-gated read reward ── // ── solo-ads inbox: list, read view, dwell-gated read reward ──
let ibTimer = null; let ibTimer = null;
function setInboxBadge(n) { function setInboxBadge(n) {
+11
View File
@@ -341,6 +341,17 @@ textarea{resize:vertical;font:inherit}
.wall-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px;text-align:center} .wall-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px;text-align:center}
.wall-card img{max-width:100%;border-radius:10px;border:1px solid var(--line-strong)} .wall-card img{max-width:100%;border-radius:10px;border:1px solid var(--line-strong)}
.wall-pos{font-family:var(--mono);font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px} .wall-pos{font-family:var(--mono);font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px}
/* ── modal (sponsor message) ── */
.modal-back{position:fixed;inset:0;z-index:100;background:rgba(2,6,5,.72);display:flex;align-items:center;justify-content:center;padding:20px}
.modal-card{background:var(--panel-solid);border:1px solid var(--line-strong);border-radius:var(--radius);
padding:24px;max-width:520px;width:100%;box-shadow:0 20px 60px rgba(0,0,0,.5)}
/* ── downline lineage list ── */
.lin-lvl{margin:10px 0}
.lin-lvl>.cap{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:6px}
.lin-row{display:flex;gap:10px;align-items:baseline;padding:6px 0;border-bottom:1px solid var(--line);flex-wrap:wrap}
.lin-row .nm{font-weight:700;min-width:120px}
.lin-row .em{font-size:12px;color:var(--mint);overflow-wrap:anywhere}
.lin-row .id{font-family:var(--mono);font-size:11px;color:var(--muted)}
/* ── solo composer: toolbar + contenteditable editor ── */ /* ── solo composer: toolbar + contenteditable editor ── */
.ed-bar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px} .ed-bar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
.ed-bar button{background:var(--panel);color:var(--ink);border:1px solid var(--line-strong);border-radius:8px; .ed-bar button{background:var(--panel);color:var(--ink);border:1px solid var(--line-strong);border-radius:8px;
+4 -4
View File
@@ -5,7 +5,7 @@
<title>The contract | InstantAdPay</title> <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."> <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="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905y"> <link rel="stylesheet" href="/assets/site.css?v=20260905z">
</head> </head>
<body> <body>
<div class="wrap"> <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> <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> </footer>
</div> </div>
<script src="/assets/common.js?v=20260905y"></script> <script src="/assets/common.js?v=20260905z"></script>
<script src="/assets/contract.js?v=20260905y"></script> <script src="/assets/contract.js?v=20260905z"></script>
<script src="/assets/chat.js?v=20260905y"></script> <script src="/assets/chat.js?v=20260905z"></script>
</body> </body>
</html> </html>
+5 -5
View File
@@ -5,7 +5,7 @@
<title>InstantAdPay: advertise and earn, locked in code</title> <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."> <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="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905y"> <link rel="stylesheet" href="/assets/site.css?v=20260905z">
</head> </head>
<body> <body>
@@ -410,9 +410,9 @@
</div> </div>
</section> </section>
<script src="/assets/common.js?v=20260905y"></script> <script src="/assets/common.js?v=20260905z"></script>
<script src="/assets/wallet.js?v=20260905y"></script> <script src="/assets/wallet.js?v=20260905z"></script>
<script src="/assets/home.js?v=20260905y"></script> <script src="/assets/home.js?v=20260905z"></script>
<script src="/assets/chat.js?v=20260905y"></script> <script src="/assets/chat.js?v=20260905z"></script>
</body> </body>
</html> </html>
+4 -4
View File
@@ -5,7 +5,7 @@
<title>Live ledger | InstantAdPay</title> <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."> <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="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905y"> <link rel="stylesheet" href="/assets/site.css?v=20260905z">
</head> </head>
<body> <body>
<div class="wrap"> <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> <div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
</footer> </footer>
</div> </div>
<script src="/assets/common.js?v=20260905y"></script> <script src="/assets/common.js?v=20260905z"></script>
<script src="/assets/ledger.js?v=20260905y"></script> <script src="/assets/ledger.js?v=20260905z"></script>
<script src="/assets/chat.js?v=20260905y"></script> <script src="/assets/chat.js?v=20260905z"></script>
</body> </body>
</html> </html>
+50 -5
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Member area | InstantAdPay</title> <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="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905y"> <link rel="stylesheet" href="/assets/site.css?v=20260905z">
</head> </head>
<body class="bo-body"> <body class="bo-body">
@@ -82,6 +82,16 @@
</div> </div>
</div> </div>
<!-- ── sponsor message modal: unmissable on sign-in ── -->
<div id="msgModal" class="modal-back" hidden>
<div class="modal-card">
<p class="eyebrow" id="mmFrom">A message from your sponsor</p>
<h3 id="mmSubject" style="margin:.2em 0 .6em"></h3>
<div id="mmBody" class="promo-block ib-rich" style="max-height:50vh;overflow:auto"></div>
<p style="margin-top:16px;text-align:right"><button class="btn" id="mmAck" type="button">Got it</button></p>
</div>
</div>
<!-- ── signed-in: back-office shell ──────────────────────── --> <!-- ── signed-in: back-office shell ──────────────────────── -->
<div id="memberArea" class="bo" hidden> <div id="memberArea" class="bo" hidden>
<aside class="bo-side" id="boSide"> <aside class="bo-side" id="boSide">
@@ -217,6 +227,41 @@
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">
<h3>Your downline, three levels deep</h3>
<p class="muted small">Everyone below you across levels 1–3. You see email addresses for your
direct referrals only; deeper levels show username and ID. You can message any of them below.</p>
<div id="lineageWrap"><p class="muted small">Loading…</p></div>
</div>
<div class="card" id="broadcastCard">
<h3>Message your team</h3>
<p class="muted small">Send one broadcast a day to your downline. It lands in their on-site inbox
and their email. Reaches the people who joined under you — they can't opt out of their sponsor,
so make it count.</p>
<p><select id="bcScope" style="width:100%">
<option value="direct">My direct referrals only (level 1)</option>
<option value="all">My whole downline (levels 1–3)</option>
</select></p>
<p><input id="bcSubject" maxlength="160" placeholder="Subject" style="width:100%"></p>
<div class="ed-bar" aria-label="Formatting">
<button type="button" data-bc="bold" title="Bold"><b>B</b></button>
<button type="button" data-bc="italic" title="Italic"><i>I</i></button>
<button type="button" data-bc="underline" title="Underline"><u>U</u></button>
<button type="button" data-bcblock="h3" title="Heading">H</button>
<button type="button" data-bc="insertUnorderedList" title="Bullet list">• List</button>
<button type="button" id="bcLinkBtn" title="Insert link">🔗 Link</button>
</div>
<div id="bcEd" class="ed-body" contenteditable="true" data-ph="Write to your team…"></div>
<p style="margin-top:12px"><button class="btn" id="bcSendBtn" type="button">Send broadcast</button>
<span class="small muted" id="bcHint"></span></p>
</div>
<div class="card" id="upMsgCard" hidden>
<h3>Messages from your upline</h3>
<div id="upList"></div>
</div>
</div> </div>
<div class="pane" id="pane-buy" hidden> <div class="pane" id="pane-buy" hidden>
@@ -484,9 +529,9 @@
</div> </div>
</div> </div>
<script src="/assets/common.js?v=20260905y"></script> <script src="/assets/common.js?v=20260905z"></script>
<script src="/assets/wallet.js?v=20260905y"></script> <script src="/assets/wallet.js?v=20260905z"></script>
<script src="/assets/my.js?v=20260905y"></script> <script src="/assets/my.js?v=20260905z"></script>
<script src="/assets/chat.js?v=20260905y"></script> <script src="/assets/chat.js?v=20260905z"></script>
</body> </body>
</html> </html>
+3 -3
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Transaction | InstantAdPay</title> <title>Transaction | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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=20260905y"> <link rel="stylesheet" href="/assets/site.css?v=20260905z">
</head> </head>
<body> <body>
<div id="nav"></div> <div id="nav"></div>
@@ -33,7 +33,7 @@
<p><a href="/ledger">← Back to the live ledger</a> · <a href="/contract">Read the contract review</a></p> <p><a href="/ledger">← Back to the live ledger</a> · <a href="/contract">Read the contract review</a></p>
</div> </div>
</section> </section>
<script src="/assets/common.js?v=20260905y"></script> <script src="/assets/common.js?v=20260905z"></script>
<script src="/assets/tx.js?v=20260905y"></script> <script src="/assets/tx.js?v=20260905z"></script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Viewing ad — InstantAdPay</title> <title>Viewing ad — InstantAdPay</title>
<link rel="stylesheet" href="/assets/site.css?v=20260905y"> <link rel="stylesheet" href="/assets/site.css?v=20260905z">
<style> <style>
html,body{height:100%;margin:0;overflow:hidden} html,body{height:100%;margin:0;overflow:hidden}
.vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)} .vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)}
@@ -43,6 +43,6 @@
</div> </div>
<iframe class="vframe" id="vFrame" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" referrerpolicy="no-referrer" title="Advertiser site"></iframe> <iframe class="vframe" id="vFrame" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" referrerpolicy="no-referrer" title="Advertiser site"></iframe>
</div> </div>
<script src="/assets/view.js?v=20260905y"></script> <script src="/assets/view.js?v=20260905z"></script>
</body> </body>
</html> </html>
+3 -3
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Banner wall | InstantAdPay</title> <title>Banner wall | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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=20260905y"> <link rel="stylesheet" href="/assets/site.css?v=20260905z">
</head> </head>
<body> <body>
<div id="nav"></div> <div id="nav"></div>
@@ -26,7 +26,7 @@
</div> </div>
</div> </div>
</section> </section>
<script src="/assets/common.js?v=20260905y"></script> <script src="/assets/common.js?v=20260905z"></script>
<script src="/assets/wall.js?v=20260905y"></script> <script src="/assets/wall.js?v=20260905z"></script>
</body> </body>
</html> </html>
+69
View File
@@ -16,6 +16,7 @@ const auth = require('./auth');
const accounts = require('./accounts'); const accounts = require('./accounts');
const ads = require('./ads'); const ads = require('./ads');
const mailer = require('./mailer'); const mailer = require('./mailer');
const messages = require('./messages');
const chatbot = require('./chatbot'); const chatbot = require('./chatbot');
const PORT = Number(process.env.PORT || 3000); const PORT = Number(process.env.PORT || 3000);
@@ -120,6 +121,7 @@ async function boot() {
accounts.init({ dataDir: DATA_DIR }); accounts.init({ dataDir: DATA_DIR });
ads.init({ dataDir: DATA_DIR, chain }); ads.init({ dataDir: DATA_DIR, chain });
mailer.init({ dataDir: DATA_DIR }); mailer.init({ dataDir: DATA_DIR });
messages.init({ dataDir: DATA_DIR });
chatbot.init({ dataDir: DATA_DIR, chain }); chatbot.init({ dataDir: DATA_DIR, chain });
setTimeout(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 1000); setTimeout(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 1000);
setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000); setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000);
@@ -459,6 +461,14 @@ const server = http.createServer(async (req, res) => {
else { out.welcomeCredits = 0; out.gauntletPending = true; } else { out.welcomeCredits = 0; out.gauntletPending = true; }
} }
if (out.email) out.inboxUnread = await ads.unreadCount(out.email); // delivers pending solos too if (out.email) out.inboxUnread = await ads.unreadCount(out.email); // delivers pending solos too
if (out.email) { // unmissable login modal when the upline sent a message
const un = await messages.newestUnread(out.email);
if (un) {
const nm = un.fromMember ? await accounts.namesForMembers([un.fromMember]) : {};
out.sponsorMsg = { id: un.id, subject: un.subject, body: un.body,
fromName: (un.fromMember && nm[un.fromMember]) ? '@' + nm[un.fromMember] : (un.fromMember ? 'member #' + un.fromMember : 'your sponsor') };
}
}
if (memberId) { if (memberId) {
try { try {
const mm = await chain.member(memberId); const mm = await chain.member(memberId);
@@ -571,6 +581,65 @@ const server = http.createServer(async (req, res) => {
const r = await ads.claimDaily(s.email); const r = await ads.claimDaily(s.email);
return json(res, r.error ? 400 : 200, r); return json(res, r.error ? 400 : 200, r);
} }
// -- downline lineage: 3 levels, usernames+IDs; email only for directs
if (p === '/api/my/line' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const levels = await accounts.downline(s.email, 3);
const out = levels.map(L => ({ level: L.level, members: L.members.map(m => ({
memberId: m.memberId || 0,
name: m.username ? '@' + m.username : m.memberId ? 'member #' + m.memberId : 'member',
email: L.level === 1 ? m.email : null, // directs only
joined: m.created })) }));
return json(res, 200, { levels: out, counts: out.map(L => L.members.length) });
}
// -- broadcast a message to your downline (1/day), on-site inbox + email
if (p === '/api/my/broadcast' && 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 subject = String(b.subject || '').trim().slice(0, 160);
const body = ads.sanitizeRich(b.body);
const plain = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (!subject) return json(res, 400, { error: 'Give your message a subject.' });
if (plain.length < 10) return json(res, 400, { error: 'Write a message first.' });
const last = await messages.lastBroadcastAt(s.email);
if (Date.now() - last < 24 * 3600 * 1000)
return json(res, 429, { error: 'You can send one broadcast a day. Try again in ' + Math.ceil((24 * 3600 * 1000 - (Date.now() - last)) / 3600000) + 'h.' });
const depth = b.scope === 'direct' ? 1 : 3;
const levels = await accounts.downline(s.email, depth);
const recipients = [...new Set(levels.flatMap(L => L.members.map(m => m.email)).filter(Boolean))];
if (!recipients.length) return json(res, 400, { error: 'No one in your line to message yet.' });
const memberId = s.memberId || await auth.refreshMemberId(s);
await messages.deliver(memberId, s.email, recipients, subject, body);
// email each recipient too (best-effort; never blocks the on-site delivery)
if (mailer.hasKey()) {
const who = (await accounts.byEmail(s.email));
const from = who && who.username ? '@' + who.username : 'your sponsor';
for (const to of recipients) {
mailer.send(to, 'Message from ' + from + ': ' + subject,
plain + '\n\n— sent via your InstantAdPay upline. Read it in your dashboard: https://instantadpay.com/my#line')
.catch(() => {});
}
}
return json(res, 200, { ok: true, sent: recipients.length });
}
// -- sponsor messages: this member's inbox from their upline
if (p === '/api/my/messages' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const items = await messages.inbox(s.email);
const names = await accounts.namesForMembers([...new Set(items.map(i => i.fromMember).filter(Boolean))]);
for (const i of items) i.fromName = (i.fromMember && names[i.fromMember]) ? '@' + names[i.fromMember]
: i.fromMember ? 'member #' + i.fromMember : 'your upline';
return json(res, 200, { items, unread: items.filter(i => !i.read).length });
}
m = /^\/api\/my\/messages\/(\d+)\/read$/.exec(p);
if (m && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await messages.markRead(s.email, m[1]));
}
// -- line banner: the member's viral slot on welcome tours + their wall // -- line banner: the member's viral slot on welcome tours + their wall
if (p === '/api/my/linebanner' && req.method === 'POST') { if (p === '/api/my/linebanner' && req.method === 'POST') {
const s = await auth.fromRequest(req); const s = await auth.fromRequest(req);