diff --git a/accounts.js b/accounts.js index aab3bd9..e74ecb9 100644 --- a/accounts.js +++ b/accounts.js @@ -36,6 +36,7 @@ const pub = a => a ? { email: a.email, sponsorRef: a.sponsorRef || '', code: a.c username: a.username || null, memberId: a.memberId || 0, lineBannerUrl: a.lineBannerUrl || null, lineTargetUrl: a.lineTargetUrl || null, avatarUrl: a.avatarUrl || null, bio: a.bio || null, socials: a.socials || null, + chatAvailable: a.chatAvailable === false ? false : true, lastSeen: a.lastSeen || 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(); @@ -117,6 +118,15 @@ const J = { this.save(); return { ok: true, account: pub(acct) }; }, + async touchSeen(e) { const a = this.db.byEmail[e]; if (a) { a.lastSeen = Date.now(); this.save(); } }, + async setChatAvailable(e, v) { const a = this.db.byEmail[e]; if (!a) return { error: 'No such account.' }; a.chatAvailable = !!v; this.save(); return { ok: true, available: !!v }; }, + async getMutes(e) { const a = this.db.byEmail[e]; return (a && Array.isArray(a.chatMutes)) ? a.chatMutes : []; }, + async setMute(owner, target, muted) { + const a = this.db.byEmail[owner]; if (!a) return { error: 'No such account.' }; + const set = new Set((a.chatMutes || []).map(x => String(x).toLowerCase())); const t = String(target).toLowerCase(); + if (muted) set.add(t); else set.delete(t); + a.chatMutes = [...set].slice(0, 500); this.save(); return { ok: true, muted: !!muted }; + }, async byMemberId(id) { const a = Object.values(this.db.byEmail).find(x => x.memberId === Number(id)); return a ? pub(a) : null; @@ -157,6 +167,7 @@ const rowPub = r => r ? pub({ email: r.email, sponsorRef: r.sponsor_ref, code: r username: r.username, memberId: r.member_id || 0, lineBannerUrl: r.line_banner_url, lineTargetUrl: r.line_target_url, avatarUrl: r.avatar_url, bio: r.bio, socials: r.socials, + chatAvailable: r.chat_available === 0 ? false : true, lastSeen: Number(r.last_seen || 0), address: r.address, created: Number(r.created) }) : null; const D = { async signup(e, password, ref) { @@ -212,6 +223,19 @@ const D = { if (socials !== undefined) await db.q('UPDATE accounts SET socials=? WHERE email=?', [socials || null, e]); return { ok: true, account: await this.byEmail(e) }; }, + async touchSeen(e) { await db.q('UPDATE accounts SET last_seen=? WHERE email=?', [Date.now(), e]); }, + async setChatAvailable(e, v) { await db.q('UPDATE accounts SET chat_available=? WHERE email=?', [v ? 1 : 0, e]); return { ok: true, available: !!v }; }, + async getMutes(e) { + const r = await db.q('SELECT chat_mutes FROM accounts WHERE email=?', [e]); + try { const arr = JSON.parse((r[0] && r[0].chat_mutes) || '[]'); return Array.isArray(arr) ? arr : []; } catch (x) { return []; } + }, + async setMute(owner, target, muted) { + const cur = await this.getMutes(owner); + const set = new Set(cur.map(x => String(x).toLowerCase())); const t = String(target).toLowerCase(); + if (muted) set.add(t); else set.delete(t); + await db.q('UPDATE accounts SET chat_mutes=? WHERE email=?', [JSON.stringify([...set].slice(0, 500)), owner]); + return { ok: true, muted: !!muted }; + }, async byMemberId(id) { const r = await db.q('SELECT * FROM accounts WHERE member_id=?', [Number(id)]); return rowPub(r[0]); @@ -309,8 +333,35 @@ async function linkWallet(email, address) { } async function count() { return impl().count(); } +// resolve a member's DIRECT sponsor account (the token they joined under) +async function sponsorOf(email) { + const root = await byEmail(email); + if (!root || !root.sponsorRef) return null; + const ref = String(root.sponsorRef); + let s = null; + try { s = await byCode(ref); } catch (e) {} + if (!s) { try { s = await byUsername(ref); } catch (e) {} } + if (!s && /^\d+$/.test(ref)) { try { s = await byMemberId(Number(ref)); } catch (e) {} } + return s || null; +} +// is `memberEmail` anywhere in `sponsorEmail`'s downline (to `depth` levels)? +async function isDownlineOf(sponsorEmail, memberEmail, depth = 3) { + const target = String(memberEmail || '').toLowerCase(); + const levels = await downline(sponsorEmail, depth); + return levels.some(L => L.members.some(m => String(m.email || '').toLowerCase() === target)); +} +async function getChatSettings(email) { + const a = await byEmail(email); + return { available: a ? a.chatAvailable !== false : true, mutes: await impl().getMutes(String(email || '').toLowerCase()) }; +} + module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername, setUsername, setMemberId, namesForMembers, listByReferrer, downline, linkWallet, count, setLineBanner: (e, b, t) => impl().setLineBanner(String(e || '').toLowerCase(), b, t), setProfile: (e, a, bio, socials) => impl().setProfile(String(e || '').toLowerCase(), a, bio, socials), + touchSeen: e => impl().touchSeen(String(e || '').toLowerCase()), + setChatAvailable: (e, v) => impl().setChatAvailable(String(e || '').toLowerCase(), v), + getMutes: e => impl().getMutes(String(e || '').toLowerCase()), + setMute: (o, t, m) => impl().setMute(String(o || '').toLowerCase(), String(t || '').toLowerCase(), m), + sponsorOf, isDownlineOf, getChatSettings, byMemberId: id => impl().byMemberId(id) }; diff --git a/chatbot.js b/chatbot.js index c614023..0b3473d 100644 --- a/chatbot.js +++ b/chatbot.js @@ -35,6 +35,8 @@ const CANNED = [ a: 'You get your share link the moment you sign in, free members included. One tip: switch on payouts (one free wallet step in Members) before your people start buying, because the contract locks each buyer to their sponsor at their first purchase.' }, { re: /(solo ad|inbox ad|inbox)/i, a: 'Solo ads are full-message ads delivered straight into member inboxes on-site. Compose one under Campaigns (pick "Solo ad"): subject line, a rich-text message with a real editor (bold, headings, lists, links), an attached image or video if you want one, and a call-to-action button with your own label. You pay 5 credits per guaranteed delivery, 10 deliveries minimum. On the reading side, your Inbox section collects solos from other members — give one a real read (10 seconds on the open message) and claim 2 credits, up to 5 rewarded reads a day. You never receive your own solo.' }, + { re: /(chat|message (my )?(sponsor|upline|team|downline)|talk to (my )?sponsor|contact (my )?sponsor|ask (my )?sponsor|reach (my )?sponsor)/i, + a: 'Yes, Members has a two-way Sponsor Chat. You can message your direct sponsor for help (from Overview or the chat button), and sponsors can message anyone in their line. If they are online you chat live; if not, your message waits in their on-site inbox and they get an email. Sponsors can set themselves unavailable in Profile (you can still leave a note, they just reply later) and mute a member if needed. It is separate from the once-a-day team broadcast.' }, { re: /((view|watch|see).{0,12}ads?|earn.{0,12}credits?|daily (set|ads|views))/i, a: 'In the Earn credits section of Members, each ad in the daily set opens full screen in its own tab, showing the advertiser\'s real site. A countdown runs while you watch (it pauses if you leave the tab), then you pass a quick click-the-icon check and the view counts. Finish the set, claim your daily credits, and spend them on your own banner or text campaigns. You never see your own ads, and viewer rewards are credits, never cash.' }, { re: /(credit|impression|cpm|what do i get|what am i buying)/i, @@ -57,6 +59,7 @@ FACTS: - Members EARN credits by attention: in the Earn credits section of Members, each ad in the daily set opens FULL SCREEN in its own tab, showing the advertiser's real website. A countdown runs while you watch (it pauses if you leave the tab), then a quick human check (click the named icon) must be passed before the view counts. Finish the daily set, claim a small daily credit batch. Earned credits spend on banner and text campaigns; attention earns advertising, referrals earn money, and viewer rewards are never cash. Advertisers get real, verified visits to their site. - Onsite SOLO ADS are live: a solo ad is a full message (subject + up to 2000 characters of formatted text + your link) delivered into members' on-site Inbox (Members > Inbox). The composer in Campaigns > Solo ad has a rich-text editor (bold, headings, lists, links), lets you ATTACH one image (PNG/JPG/WebP/GIF, up to 3MB) or one video (MP4/WebM, up to 25MB), and adds a call-to-action button with a custom label that opens the target URL. Cost 5 credits per guaranteed delivery, minimum 10 deliveries (50 credits). Each member receives a given solo at most once, and never the sender's own. Readers earn 2 credits per real read (10-second dwell on the open message, up to 5 rewarded reads/day) — claimed right from the message. Compose one in Campaigns > Solo ad. - Campaign target URLs are checked the moment they are submitted: the page must be reachable and must ALLOW framing (no X-Frame-Options deny/sameorigin, no blocking CSP frame-ancestors), because surf views show the real site full screen. Frame-blocking or dead URLs are rejected with the exact reason; the fix is a landing page that allows framing. Login-ad targets skip the frame check (they are click-through only). +- SPONSOR CHAT (Members): a two-way, one-to-one chat. A member can start a conversation with their DIRECT sponsor; a sponsor can message anyone in their downline. Presence-aware — when both are online it is live, otherwise a message waits in the recipient's on-site inbox and sends an email. Each member has an availability toggle in Profile (default on; turning it off stops live chats but people can still leave a note) and can mute a specific person. This is separate from the once-a-day sponsor BROADCAST to the whole downline. - Every purchase is split by an immutable smart contract in the same transaction: 50% direct sponsor, 20% level 2, 10% level 3, 20% platform. No withdrawals exist; money lands in members' own wallets instantly. - Qualification: level 1 open to all; 2 buyers of $20+ unlock level 2; 5 unlock level 3. Unqualified shares pass up the sponsor line, checking up to 25 positions, else the platform receives them. Qualification cannot be bought and never expires. - Every member gets a share link immediately (site code, /join/); the contract locks a buyer to their sponsor at the buyer's FIRST purchase, so members should switch on payouts before their referrals buy. diff --git a/db.js b/db.js index 53726ab..6cfc415 100644 --- a/db.js +++ b/db.js @@ -136,6 +136,12 @@ async function bootstrap() { read_ts BIGINT NULL, INDEX (to_email, read_ts), INDEX (from_email, sent) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + // sponsor CHAT (two-way) rides the same table: kind='chat' vs the default 'broadcast' + await alterSafe(`ALTER TABLE sponsor_messages ADD COLUMN kind VARCHAR(12) NOT NULL DEFAULT 'broadcast'`); + await alterSafe('ALTER TABLE sponsor_messages ADD INDEX idx_pair (to_email, from_email)'); + // per-account chat settings: availability toggle (default on) + muted-member list (JSON emails) + await alterSafe('ALTER TABLE accounts ADD COLUMN chat_available TINYINT NOT NULL DEFAULT 1'); + await alterSafe('ALTER TABLE accounts ADD COLUMN chat_mutes VARCHAR(4000) NULL'); 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 index 79bc3d3..b2f6bcf 100644 --- a/messages.js +++ b/messages.js @@ -32,53 +32,130 @@ async function deliver(fromMember, fromEmail, recipients, subject, body) { 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 (?,?,?,?,?,?)', + await db.q("INSERT INTO sponsor_messages (from_member,from_email,to_email,subject,body,sent,kind) VALUES (?,?,?,?,?,?,'broadcast')", [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 }); + J.db.items.push({ id: J.db.nextId++, kind: 'broadcast', fromMember: fromMember || 0, fromEmail, toEmail: to, subject, body, sent: now, readTs: 0 }); n++; } J.save(); } return n; } +const isBroadcast = i => (i.kind || 'broadcast') === 'broadcast'; +const isChat = i => i.kind === 'chat'; 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]); + WHERE to_email=? AND kind='broadcast' 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) + return J.db.items.filter(i => i.toEmail === e && isBroadcast(i)).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 })); } + +// ── two-way sponsor CHAT (kind='chat'), plain-text 1:1 threads ── +async function sendChat(fromMember, fromEmail, toEmail, body) { + const now = Date.now(); + const from = String(fromEmail || '').toLowerCase(), to = String(toEmail || '').toLowerCase(); + if (db.enabled()) { + const r = await db.q("INSERT INTO sponsor_messages (from_member,from_email,to_email,subject,body,sent,kind) VALUES (?,?,?,'',?,?,'chat')", + [fromMember || 0, from, to, body, now]); + return { id: r.insertId, sent: now }; + } + if (!J.db) J.load(); + const id = J.db.nextId++; + J.db.items.push({ id, kind: 'chat', fromMember: fromMember || 0, fromEmail: from, toEmail: to, subject: '', body, sent: now, readTs: 0 }); + J.save(); + return { id, sent: now }; +} +// messages between two members (both directions), id > afterId, oldest→newest +async function thread(aEmail, bEmail, afterId = 0, limit = 300) { + const a = String(aEmail || '').toLowerCase(), b = String(bEmail || '').toLowerCase(); + if (db.enabled()) { + const rows = await db.q(`SELECT id, from_member, from_email, body, sent, read_ts FROM sponsor_messages + WHERE kind='chat' AND id>? AND ((from_email=? AND to_email=?) OR (from_email=? AND to_email=?)) + ORDER BY id ASC LIMIT ?`, [Number(afterId) || 0, a, b, b, a, limit]); + return rows.map(r => ({ id: r.id, fromMember: r.from_member, fromEmail: r.from_email, body: r.body, sent: Number(r.sent), read: !!r.read_ts })); + } + if (!J.db) J.load(); + return J.db.items.filter(i => isChat(i) && i.id > (Number(afterId) || 0) + && ((i.fromEmail === a && i.toEmail === b) || (i.fromEmail === b && i.toEmail === a))) + .sort((x, y) => x.id - y.id).slice(0, limit) + .map(i => ({ id: i.id, fromMember: i.fromMember, fromEmail: i.fromEmail, body: i.body, sent: i.sent, read: !!i.readTs })); +} +// mark every chat message FROM other → email as read +async function markChatRead(email, otherEmail) { + const e = String(email || '').toLowerCase(), o = String(otherEmail || '').toLowerCase(); + if (db.enabled()) { + await db.q("UPDATE sponsor_messages SET read_ts=? WHERE kind='chat' AND to_email=? AND from_email=? AND read_ts IS NULL", [Date.now(), e, o]); + return { ok: true }; + } + if (!J.db) J.load(); + let touched = false; + for (const i of J.db.items) if (isChat(i) && i.toEmail === e && i.fromEmail === o && !i.readTs) { i.readTs = Date.now(); touched = true; } + if (touched) J.save(); + return { ok: true }; +} +async function chatUnread(email) { + const e = String(email || '').toLowerCase(); + if (db.enabled()) { + const r = await db.q("SELECT COUNT(*) n FROM sponsor_messages WHERE kind='chat' AND to_email=? AND read_ts IS NULL", [e]); + return r[0].n; + } + if (!J.db) J.load(); + return J.db.items.filter(i => isChat(i) && i.toEmail === e && !i.readTs).length; +} +// one row per conversation partner: newest message + my unread count from them +async function threadList(email) { + const e = String(email || '').toLowerCase(); + let rows; + if (db.enabled()) { + rows = (await db.q(`SELECT from_email, to_email, body, sent, read_ts FROM sponsor_messages + WHERE kind='chat' AND (from_email=? OR to_email=?) ORDER BY sent DESC LIMIT 800`, [e, e])) + .map(r => ({ fromEmail: r.from_email, toEmail: r.to_email, body: r.body, sent: Number(r.sent), read: !!r.read_ts })); + } else { + if (!J.db) J.load(); + rows = J.db.items.filter(i => isChat(i) && (i.fromEmail === e || i.toEmail === e)) + .sort((a, b) => b.sent - a.sent) + .map(i => ({ fromEmail: i.fromEmail, toEmail: i.toEmail, body: i.body, sent: i.sent, read: !!i.readTs })); + } + const byOther = new Map(); + for (const r of rows) { + const other = r.fromEmail === e ? r.toEmail : r.fromEmail; + if (!byOther.has(other)) byOther.set(other, { email: other, last: { body: r.body, sent: r.sent, fromMe: r.fromEmail === e }, unread: 0 }); + if (r.toEmail === e && !r.read) byOther.get(other).unread++; + } + return [...byOther.values()]; +} 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]); + const r = await db.q("SELECT COUNT(*) n FROM sponsor_messages WHERE to_email=? AND kind='broadcast' 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; + return J.db.items.filter(i => i.toEmail === e && isBroadcast(i) && !i.readTs).length; } -// the newest unread message (for the login modal) +// the newest unread BROADCAST (for the sign-in modal; chat never pops the 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]); + WHERE to_email=? AND kind='broadcast' 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]; + const u = J.db.items.filter(i => i.toEmail === e && isBroadcast(i) && !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) { @@ -93,4 +170,5 @@ async function markRead(email, id) { return { ok: true }; } -module.exports = { init, lastBroadcastAt, deliver, inbox, unreadCount, newestUnread, markRead }; +module.exports = { init, lastBroadcastAt, deliver, inbox, unreadCount, newestUnread, markRead, + sendChat, thread, markChatRead, chatUnread, threadList }; diff --git a/public/assets/my.js b/public/assets/my.js index 6c187df..8a5703d 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -205,6 +205,7 @@ try { const d = await (await fetch('/api/my/dashboard')).json(); if (d.error) return; + chatSync(d); const wc = d.welcomeCredits || 0; $('dbCredits').textContent = ((d.credits || 0) + wc).toLocaleString(); $('dbCreditsSub').textContent = wc ? (d.credits || 0).toLocaleString() + ' purchased · ' + wc + ' welcome' : ''; @@ -761,8 +762,12 @@ '
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('') + + '' + new Date(m.joined).toLocaleDateString() + '' + + (m.email ? '' : '') + + '
').join('') + '').join(''); + el.querySelectorAll('[data-cemail]').forEach(b => + b.addEventListener('click', () => openConvo(b.dataset.cemail, b.dataset.cname))); } catch (e) {} } async function loadUplineMessages() { @@ -1330,6 +1335,135 @@ } catch (e) {} } + // ── SPONSOR CHAT: two-way threads, presence-aware, with a slide-in drawer ── + let CHAT_ME = null, CHAT_OTHER = null, CHAT_LASTID = 0, CHAT_POLL = null, CHAT_CANMUTE = false, CHAT_IMUTE = false; + function chatSync(d) { + CHAT_ME = d.email || CHAT_ME; + const fab = $('chatFab'); if (fab) fab.hidden = !d.email; + setChatBadge(d.chatUnread || 0); + const tog = $('chatAvailToggle'); if (tog) tog.checked = d.chatAvailable !== false; + const qs = $('qaMsgSponsor'); + if (qs) { + if (d.sponsor && d.sponsor.email) { qs.hidden = false; qs.dataset.email = d.sponsor.email; qs.dataset.name = d.sponsor.name || 'your sponsor'; } + else qs.hidden = true; + } + } + function setChatBadge(n) { const b = $('chatFabBadge'); if (b) { b.hidden = !n; b.textContent = n > 9 ? '9+' : n; } } + function stopPoll() { if (CHAT_POLL) { clearInterval(CHAT_POLL); CHAT_POLL = null; } } + function startPoll() { stopPoll(); CHAT_POLL = setInterval(() => pullThread(false), 4000); } + function autoGrow(el) { el.style.height = 'auto'; el.style.height = Math.min(120, el.scrollHeight) + 'px'; } + function closeDrawer() { stopPoll(); if ($('chatDrawer')) $('chatDrawer').hidden = true; CHAT_OTHER = null; loadDashboard(); } + + async function openThreads() { + const dr = $('chatDrawer'); if (!dr) return; + dr.hidden = false; $('chatConvo').hidden = true; $('chatThreads').hidden = false; + $('chatBack').hidden = true; $('chatMute').hidden = true; $('chatDot').hidden = true; + $('chatTitle').textContent = 'Messages'; $('chatStatus').textContent = ''; + stopPoll(); CHAT_OTHER = null; + $('chatThreads').innerHTML = '

Loading…

'; + try { + const r = await (await fetch('/api/my/chat/threads')).json(); + const list = r.threads || []; + if (!list.length) { $('chatThreads').innerHTML = '
No conversations yet. Message your sponsor from Overview, or anyone in your line from “My line”.
'; return; } + $('chatThreads').innerHTML = list.map(t => + '
' + + '' + + '
' + esc(t.name) + '
' + + '
' + (t.last.fromMe ? 'You: ' : '') + esc((t.last.body || '').slice(0, 64)) + '
' + + (t.unread ? '' + t.unread + '' : '') + '
').join(''); + $('chatThreads').querySelectorAll('.chat-thread').forEach(el => + el.addEventListener('click', () => openConvo(el.dataset.email, el.dataset.name))); + } catch (e) { $('chatThreads').innerHTML = '
Could not load messages.
'; } + } + + async function openConvo(email, name) { + if (!email) return; + CHAT_OTHER = email; CHAT_LASTID = 0; + const dr = $('chatDrawer'); if (!dr) return; + dr.hidden = false; $('chatThreads').hidden = true; $('chatConvo').hidden = false; + $('chatBack').hidden = false; $('chatDot').hidden = false; + $('chatTitle').textContent = name || email; $('chatStatus').textContent = '…'; + $('chatMsgs').innerHTML = ''; $('chatBanner').hidden = true; + $('chatInput').disabled = false; $('chatSend').disabled = false; + await pullThread(true); startPoll(); + setTimeout(() => { const i = $('chatInput'); if (i) i.focus(); }, 60); + } + + function renderMsgs(msgs) { + const box = $('chatMsgs'); if (!box) return; + const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 60; + for (const m of msgs) { + if (m.id <= CHAT_LASTID) continue; + CHAT_LASTID = Math.max(CHAT_LASTID, m.id); + const d = document.createElement('div'); + d.className = 'cbub ' + (m.fromMe ? 'me' : 'them'); + d.textContent = m.body; + const t = document.createElement('span'); t.className = 'ct-time'; + t.textContent = new Date(m.sent).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + d.appendChild(t); box.appendChild(d); + } + if (atBottom) box.scrollTop = box.scrollHeight; + } + + async function pullThread(reset) { + if (!CHAT_OTHER) return; + try { + const r = await (await fetch('/api/my/chat/thread?with=' + encodeURIComponent(CHAT_OTHER) + '&after=' + (reset ? 0 : CHAT_LASTID))).json(); + if (r.error) { $('chatBanner').hidden = false; $('chatBanner').textContent = r.error; return; } + if (reset) { $('chatMsgs').innerHTML = ''; CHAT_LASTID = 0; } + renderMsgs(r.messages || []); + $('chatDot').className = 'pres-dot' + (r.online ? ' on' : ''); + $('chatStatus').textContent = r.online ? 'active now' : (r.available ? 'away · will get your note' : 'not taking live chats · leave a note'); + CHAT_CANMUTE = !!r.canMute; CHAT_IMUTE = !!r.iMute; + const mb = $('chatMute'); mb.hidden = !CHAT_CANMUTE; mb.textContent = CHAT_IMUTE ? 'Unmute' : 'Mute'; + const blocked = !!r.blocked; + $('chatInput').disabled = blocked; $('chatSend').disabled = blocked; + if (blocked) { $('chatBanner').hidden = false; $('chatBanner').textContent = 'They are not accepting messages from you right now.'; } + else if (CHAT_IMUTE) { $('chatBanner').hidden = false; $('chatBanner').textContent = 'You muted this member. Unmute to let them message you again.'; } + else $('chatBanner').hidden = true; + } catch (e) {} + } + + async function chatSend() { + const inp = $('chatInput'); if (!inp) return; + const text = inp.value.trim(); if (!text || !CHAT_OTHER) return; + $('chatSend').disabled = true; + try { + const r = await api('/api/my/chat/send', { to: CHAT_OTHER, body: text }); + inp.value = ''; autoGrow(inp); renderMsgs([r.message]); + const box = $('chatMsgs'); box.scrollTop = box.scrollHeight; + } catch (e) { IAP.status(e.message || 'Could not send.', 'bad'); } + finally { $('chatSend').disabled = false; inp.focus(); } + } + + async function toggleMute() { + if (!CHAT_OTHER) return; + try { const r = await api('/api/my/chat/mute', { email: CHAT_OTHER, muted: !CHAT_IMUTE }); CHAT_IMUTE = r.muted; $('chatMute').textContent = CHAT_IMUTE ? 'Unmute' : 'Mute'; pullThread(false); } + catch (e) { IAP.status(e.message, 'bad'); } + } + + (function chatInit() { + if (!$('chatDrawer')) return; + const on = (id, ev, fn) => { const el = $(id); if (el) el.addEventListener(ev, fn); }; + on('chatFab', 'click', openThreads); + on('chatClose', 'click', closeDrawer); + on('chatBack', 'click', openThreads); + on('chatMute', 'click', toggleMute); + on('chatSend', 'click', chatSend); + const inp = $('chatInput'); + if (inp) { + inp.addEventListener('input', () => autoGrow(inp)); + inp.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chatSend(); } }); + } + on('qaMsgSponsor', 'click', () => { const q = $('qaMsgSponsor'); if (q.dataset.email) openConvo(q.dataset.email, q.dataset.name || 'your sponsor'); }); + on('chatAvailToggle', 'change', async () => { + const t = $('chatAvailToggle'); + try { await api('/api/my/chat/available', { available: t.checked }); IAP.status(t.checked ? 'You are available to chat with your line.' : 'Live chat off. People can still leave you a note.', 'ok'); } + catch (e) { IAP.status(e.message, 'bad'); t.checked = !t.checked; } + }); + setInterval(() => { fetch('/api/my/ping').catch(() => {}); }, 45000); + })(); + // ad surfaces: a banner greets the sign-in screen; members see live // banner + text placements inside the back office (they ARE the audience) IAP.adSlot('banner', 'adSlotLogin'); diff --git a/public/assets/site.css b/public/assets/site.css index 299ead6..45f6dbd 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -511,3 +511,47 @@ img{max-width:100%} .feed .row:first-child{animation:landed .9s ease} @keyframes landed{from{background:rgba(67,232,195,.3)}to{background:var(--mint-soft)}} } + +/* ── Sponsor chat: FAB, slide-in drawer, threads, bubbles, presence ── */ +.chat-fab{position:fixed;right:20px;bottom:20px;z-index:60;width:56px;height:56px;border-radius:50%; + border:1px solid var(--line-strong);background:var(--mint);color:var(--mint-ink);display:grid;place-items:center; + cursor:pointer;box-shadow:0 10px 30px rgba(0,0,0,.4);transition:transform .12s ease} +.chat-fab:hover{transform:translateY(-2px)} +.chat-fab-badge{position:absolute;top:-4px;right:-4px;min-width:20px;height:20px;padding:0 5px;border-radius:10px; + background:var(--bad);color:#2a0d08;font:700 12px/20px var(--disp);text-align:center} +.chat-drawer{position:fixed;right:0;top:0;bottom:0;width:380px;max-width:92vw;z-index:70;display:flex;flex-direction:column; + background:var(--panel-solid);border-left:1px solid var(--line-strong);box-shadow:-16px 0 40px rgba(0,0,0,.45); + transform:translateX(0);animation:chatIn .16s ease} +@keyframes chatIn{from{transform:translateX(24px);opacity:.4}to{transform:translateX(0);opacity:1}} +@media (prefers-reduced-motion:reduce){.chat-drawer{animation:none}.chat-fab{transition:none}} +.chat-head{display:flex;align-items:center;gap:8px;padding:12px 12px;border-bottom:1px solid var(--line)} +.chat-who{display:flex;flex-direction:column;line-height:1.15;min-width:0;flex:1} +.chat-who b{font:700 15px var(--disp);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.chat-icon{background:none;border:0;color:var(--muted);font-size:26px;line-height:1;cursor:pointer;padding:0 6px} +.chat-icon:hover{color:var(--ink)} +.pres-dot{width:9px;height:9px;border-radius:50%;background:var(--muted);flex:0 0 auto} +.pres-dot.on{background:var(--mint);box-shadow:0 0 8px var(--mint)} +.chat-threads{flex:1;overflow:auto} +.chat-thread{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--line);cursor:pointer} +.chat-thread:hover{background:var(--mint-soft)} +.chat-thread .ct-main{flex:1;min-width:0} +.chat-thread .ct-name{font:700 14px var(--disp)} +.chat-thread .ct-last{color:var(--muted);font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.chat-thread .ct-un{min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:var(--mint);color:var(--mint-ink); + font:700 12px/20px var(--disp);text-align:center} +.chat-empty{padding:22px 16px;color:var(--muted);font-size:14px} +.chat-convo{flex:1;display:flex;flex-direction:column;min-height:0} +.chat-msgs{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:8px} +.cbub{max-width:78%;padding:9px 12px;border-radius:14px;font-size:14px;line-height:1.4;white-space:pre-wrap;word-wrap:break-word} +.cbub.them{align-self:flex-start;background:var(--panel);border:1px solid var(--line);border-bottom-left-radius:4px} +.cbub.me{align-self:flex-end;background:var(--mint);color:var(--mint-ink);border-bottom-right-radius:4px} +.cbub .ct-time{display:block;margin-top:4px;font-size:10.5px;opacity:.6} +.chat-day{align-self:center;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.05em;margin:6px 0} +.chat-banner{padding:8px 14px;border-top:1px solid var(--line);color:var(--muted);font-size:12.5px;background:var(--ground2)} +.chat-compose{display:flex;gap:8px;padding:10px;border-top:1px solid var(--line);align-items:flex-end} +.chat-compose textarea{flex:1;resize:none;max-height:120px;background:var(--ground2);border:1px solid var(--line); + border-radius:12px;color:var(--ink);padding:9px 11px;font:400 14px var(--disp)} +.chat-compose .btn{padding:9px 16px} +.chat-msg-btn{margin-left:auto;font-size:12px;padding:3px 10px} +.switch{display:flex;align-items:center;gap:10px;cursor:pointer;font-size:14px} +.switch input{width:18px;height:18px;accent-color:var(--mint)} diff --git a/public/my.html b/public/my.html index 8325a82..d4c8021 100644 --- a/public/my.html +++ b/public/my.html @@ -4,7 +4,7 @@ Member area | InstantAdPay - + @@ -207,6 +207,7 @@ +

Earning levels

@@ -239,7 +240,7 @@

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.

+ direct referrals only; deeper levels show username and ID. Message your direct referrals right here.

Loading…

@@ -562,10 +563,18 @@

+
+

Sponsor chat

+

Your line can message you one-to-one for help, and you can message anyone + below you. When you are online you chat live; when you are away, messages wait in your inbox and + we email you. Turn this off and you stop taking live chats — people can still leave you a note, + you just reply on your own time.

+ +

Account details

…

-

More profile fields (avatar, bio, links) are on the roadmap.

@@ -595,9 +604,33 @@ - - - - + + + + + + + + diff --git a/server.js b/server.js index 95416d1..a36bf3c 100644 --- a/server.js +++ b/server.js @@ -464,6 +464,17 @@ const server = http.createServer(async (req, res) => { else { out.welcomeCredits = 0; out.gauntletPending = true; } } if (out.email) out.inboxUnread = await ads.unreadCount(out.email); // delivers pending solos too + if (out.email) { // sponsor chat: presence heartbeat + unread + my availability + direct sponsor + accounts.touchSeen(out.email).catch(() => {}); + out.chatUnread = await messages.chatUnread(out.email); + out.chatAvailable = (acct && acct.chatAvailable !== false); + const spon = await accounts.sponsorOf(out.email); + if (spon && spon.email) out.sponsor = { + email: spon.email, + name: spon.username ? '@' + spon.username : (spon.memberId ? 'member #' + spon.memberId : 'your sponsor'), + online: (Date.now() - (spon.lastSeen || 0)) < 60000, + available: spon.chatAvailable !== false }; + } // achievement milestones (same ladder as the Overview stepper) + one-time credit bonuses { const bc = out.buyerCount || 0; @@ -654,6 +665,103 @@ const server = http.createServer(async (req, res) => { if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); return json(res, 200, await messages.markRead(s.email, m[1])); } + + // ── SPONSOR CHAT (two-way): presence-aware 1:1 threads up/down the line ── + const chatOnline = ts => (Date.now() - (ts || 0)) < 60000; + const chatName = a => !a ? 'member' : (a.username ? '@' + a.username : (a.memberId ? 'member #' + a.memberId : 'member')); + // lightweight presence heartbeat (called on a timer while the dashboard is open) + if (p === '/api/my/ping' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (s && s.email) accounts.touchSeen(s.email).catch(() => {}); + return json(res, 200, { ok: true }); + } + if (p === '/api/my/chat/send' && req.method === 'POST') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const me = s.email.toLowerCase(); + const b = await readBody(req); + const to = String(b.to || '').trim().toLowerCase(); + const text = String(b.body || '').replace(/<[^>]*>/g, '').replace(/\s+$/, '').slice(0, 2000).trim(); + if (!to || to === me) return json(res, 400, { error: 'Pick who to message.' }); + if (!text) return json(res, 400, { error: 'Write a message first.' }); + const target = await accounts.byEmail(to); + if (!target) return json(res, 404, { error: 'No such member.' }); + // authorize: existing thread, my direct sponsor, or someone in my downline + let ok = (await messages.thread(me, to, 0, 1)).length > 0; + if (!ok) { const spon = await accounts.sponsorOf(me); ok = !!(spon && spon.email && spon.email.toLowerCase() === to); } + if (!ok) ok = await accounts.isDownlineOf(me, to); + if (!ok) return json(res, 403, { error: 'You can only message your direct sponsor or someone in your line.' }); + if ((await accounts.getMutes(to)).map(x => String(x).toLowerCase()).includes(me)) + return json(res, 403, { error: 'They are not accepting messages from you right now.' }); + const memberId = s.memberId || await auth.refreshMemberId(s); + const msg = await messages.sendChat(memberId, me, to, text); + // email only when they are offline AND I have not messaged them in ~10 min (no mid-chat spam) + try { + if (mailer.hasKey() && !chatOnline(target.lastSeen)) { + const mine = (await messages.thread(me, to, 0, 400)).filter(x => x.id !== msg.id && String(x.fromEmail).toLowerCase() === me); + const lastMineTs = mine.length ? mine[mine.length - 1].sent : 0; + if (Date.now() - lastMineTs > 10 * 60 * 1000) { + const who = await accounts.byEmail(me); + const from = who && who.username ? '@' + who.username : 'someone in your InstantAdPay line'; + mailer.send(to, 'New message from ' + from, + text.slice(0, 400) + '\n\n— reply in your dashboard: https://instantadpay.com/my').catch(() => {}); + } + } + } catch (e) {} + return json(res, 200, { ok: true, message: { id: msg.id, sent: msg.sent, fromMe: true, body: text } }); + } + if (p === '/api/my/chat/thread' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const me = s.email.toLowerCase(); + const other = String(u.searchParams.get('with') || '').trim().toLowerCase(); + const after = Number(u.searchParams.get('after')) || 0; + if (!other) return json(res, 400, { error: 'Who with?' }); + // may view a thread I'm party to (existing), or one I'm allowed to start + let ok = (await messages.thread(me, other, 0, 1)).length > 0; + if (!ok) { const spon = await accounts.sponsorOf(me); ok = !!(spon && spon.email && spon.email.toLowerCase() === other); } + if (!ok) ok = await accounts.isDownlineOf(me, other); + if (!ok) return json(res, 403, { error: 'Not your conversation.' }); + const msgs = await messages.thread(me, other, after, 300); + await messages.markChatRead(me, other); + accounts.touchSeen(me).catch(() => {}); + const oa = await accounts.byEmail(other); + const iMute = (await accounts.getMutes(me)).map(x => String(x).toLowerCase()).includes(other); + const theyMuteMe = (await accounts.getMutes(other)).map(x => String(x).toLowerCase()).includes(me); + return json(res, 200, { + messages: msgs.map(x => ({ id: x.id, fromMe: String(x.fromEmail).toLowerCase() === me, body: x.body, sent: x.sent })), + otherName: chatName(oa), online: oa ? chatOnline(oa.lastSeen) : false, + available: oa ? oa.chatAvailable !== false : true, iMute, blocked: theyMuteMe, + canMute: await accounts.isDownlineOf(me, other) }); + } + if (p === '/api/my/chat/threads' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const list = await messages.threadList(s.email.toLowerCase()); + for (const t of list) { + const a = await accounts.byEmail(t.email); + t.name = chatName(a); t.online = a ? chatOnline(a.lastSeen) : false; + } + list.sort((x, y) => (y.last.sent || 0) - (x.last.sent || 0)); + const meAcct = await accounts.byEmail(s.email); + return json(res, 200, { threads: list, available: meAcct ? meAcct.chatAvailable !== false : true }); + } + if (p === '/api/my/chat/available' && 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); + return json(res, 200, await accounts.setChatAvailable(s.email, !!b.available)); + } + if (p === '/api/my/chat/mute' && 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 target = String(b.email || '').trim().toLowerCase(); + if (!target) return json(res, 400, { error: 'Who?' }); + const allowed = (await accounts.isDownlineOf(s.email, target)) || (await messages.thread(s.email.toLowerCase(), target, 0, 1)).length > 0; + if (!allowed) return json(res, 403, { error: 'You can only mute someone in your line.' }); + return json(res, 200, await accounts.setMute(s.email, target, !!b.muted)); + } // -- featured rotation: the live featured links + dilution stats if (p === '/api/featured' && req.method === 'GET') { const items = await ads.serveFeatured();