diff --git a/accounts.js b/accounts.js index 8b9b02c..8f18808 100644 --- a/accounts.js +++ b/accounts.js @@ -261,6 +261,30 @@ async function setUsername(email, username) { 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 || []); } +// 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) { const a = normAddr(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(); } 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), byMemberId: id => impl().byMemberId(id) }; diff --git a/ads.js b/ads.js index 5875491..a372a21 100644 Binary files a/ads.js and b/ads.js differ diff --git a/db.js b/db.js index 92e9ca7..ae6a31f 100644 --- a/db.js +++ b/db.js @@ -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 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 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 ( id VARCHAR(32) PRIMARY KEY, member_id INT NOT NULL, diff --git a/messages.js b/messages.js new file mode 100644 index 0000000..79bc3d3 --- /dev/null +++ b/messages.js @@ -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 }; diff --git a/public/assets/my.js b/public/assets/my.js index dd3c7a6..ba8c2a1 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -146,6 +146,7 @@ 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'; } setInboxBadge(d.inboxUnread || 0); + if (d.sponsorMsg) showSponsorModal(d.sponsorMsg); loadCharts(d); $('nextMove').textContent = nextMove(d); renderSteps(d); @@ -228,6 +229,7 @@ if ($('boTitle')) $('boTitle').textContent = TITLES[name]; if (name === 'earn') setEarnSub(earnSub); // refresh whichever sub-tab is active if (name === 'profile') loadLineBanner(); + if (name === 'line') { loadLineage(); loadUplineMessages(); } document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name); } @@ -537,6 +539,65 @@ } $('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 = '

No one in your downline yet. Share your link and it fills in here.

'; + return; + } + el.innerHTML = r.levels.map(L => !L.members.length ? '' : + '
Level ' + L.level + ' · ' + L.members.length + (L.level === 1 ? ' direct' : '') + '
' + + L.members.map(m => '
' + esc(m.name) + '' + + (m.email ? '' + esc(m.email) + '' : '#' + m.memberId + '') + + '' + new Date(m.joined).toLocaleDateString() + '
').join('') + + '
').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 => '
' + + '
' + esc(i.subject) + '' + + '' + esc(i.fromName) + ' · ' + new Date(i.sent).toLocaleDateString() + '
' + + '
' + (i.body || '') + '
').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 ── let ibTimer = null; function setInboxBadge(n) { diff --git a/public/assets/site.css b/public/assets/site.css index c3e1f64..237df02 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -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 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} +/* ── 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 ── */ .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; diff --git a/public/contract.html b/public/contract.html index f9f6b4d..cbdde74 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 9b5d9fc..1f637a3 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ InstantAdPay: advertise and earn, locked in code - + @@ -410,9 +410,9 @@ - - - - + + + + diff --git a/public/ledger.html b/public/ledger.html index a82d7d1..9601b53 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 695cab7..29f9290 100644 --- a/public/my.html +++ b/public/my.html @@ -4,7 +4,7 @@ Member area | InstantAdPay - + @@ -82,6 +82,16 @@ + + + + +
+

Your downline, three levels deep

+

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.

+

Loading…

+
+ +
+

Message your team

+

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.

+

+

+
+ + + + + + +
+
+

+

+
+ + - - - - + + + + diff --git a/public/tx.html b/public/tx.html index 589f6d8..aa32169 100644 --- a/public/tx.html +++ b/public/tx.html @@ -4,7 +4,7 @@ Transaction | InstantAdPay - + @@ -33,7 +33,7 @@

← Back to the live ledger · Read the contract review

- - + + diff --git a/public/view.html b/public/view.html index a3d086f..f2f9e83 100644 --- a/public/view.html +++ b/public/view.html @@ -4,7 +4,7 @@ Viewing ad — InstantAdPay - +