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:
+25
-1
@@ -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) };
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user