commit 010e8d7ffc663b1836e8bd4b14c5fcede9da72e6 Author: martbost Date: Tue Sep 15 16:16:52 2026 -0500 LinkSpin test area: InstantAdPay engine fork rebranded, network registry, sponsor carry-over with engine activation and claim window, rotator with /r/ redirects, link-domain mini-sites Co-Authored-By: Claude Fable 5.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..720089c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data/ +*.log +qa/out/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..efc880b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine +RUN apk add --no-cache ffmpeg ttf-dejavu +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev --no-audit --no-fund +COPY . . +RUN mkdir -p /app/data +ENV NODE_ENV=production +ENV PORT=3000 +EXPOSE 3000 +CMD ["node","server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..310d569 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# LinkSpin — site + +Membership advertising with immutable on-chain settlement. Zero-dependency +Node server (RM Circle pattern): static pages + JSON API + SSE ledger. + +## Architecture + +- `server.js` — http server: pages, `/api/*`, `/join/` sponsor links, SSE feed +- `chain.js` — contract reader + persistent event indexer (free public RPCs) +- `auth.js` — SIWE wallet sign-in (EIP-4361), sessions in the volume +- `accounts.js` — site-side records only (free members, last-touch sponsor + attribution, handles). The CHAIN is the source of truth for money/credits. +- `public/` — landing, live ledger, member area; no client libraries + +## Chain flip (rehearsal → mainnet) + +Everything chain-specific lives in `data/config.json` (volume): +`{contract, chainId, chainName, explorer, rpcs, deployBlock}`. +Defaults point at the **Amoy rehearsal** deployment. Launch = deploy the +mainnet contract, wipe `accounts.json`/`sessions.json`/`chain-index.json`, +PATCH `/api/admin/chain` with the mainnet values, set `rehearsal:false` via +`/api/admin/site`. Same code, different config. + +## Run + +``` +PORT=3100 node server.js # DATA_DIR defaults to ./data +``` + +Admin API auth: `Authorization: Bearer $ADMIN_PASSWORD`. + +Spec: `../CONTRACT-SPEC.md` (v1.0.3-frozen). Contracts: `../contracts/`. diff --git a/accounts.js b/accounts.js new file mode 100644 index 0000000..3bd17ee --- /dev/null +++ b/accounts.js @@ -0,0 +1,491 @@ +// Site-side member accounts. Dual-mode: +// MySQL (db.enabled) for real concurrency in production, +// JSON volume file as the no-DATABASE_URL fallback (local dev). +// All exported functions are async; both modes return identical shapes. +// The chain remains the source of truth for money/credits/qualification. +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const db = require('./db'); + +let DATA_DIR = null; + +// ---- shared helpers ---- +function hashPassword(password) { + const salt = crypto.randomBytes(16); + const hash = crypto.scryptSync(String(password), salt, 32); + return salt.toString('hex') + ':' + hash.toString('hex'); +} +function checkPassword(password, stored) { + try { + const [saltHex, hashHex] = String(stored).split(':'); + const hash = crypto.scryptSync(String(password), Buffer.from(saltHex, 'hex'), 32); + return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex')); + } catch (e) { return false; } +} +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; +const normEmail = e => String(e || '').trim().toLowerCase(); +const normAddr = a => String(a || '').trim().toLowerCase(); +function newCode(taken) { + let c; + do { c = crypto.randomBytes(5).toString('base64url').replace(/[-_]/g, '').slice(0, 7).toLowerCase(); } + while (!c || c.length < 6 || /^\d+$/.test(c) || (taken && taken(c))); + return c; +} +const pub = a => a ? { email: a.email, sponsorRef: a.sponsorRef || '', code: a.code || null, + username: a.username || null, memberId: a.memberId || 0, joinedVia: a.joinedVia || null, joinedRef: a.joinedRef || null, + lineBannerUrl: a.lineBannerUrl || null, lineTargetUrl: a.lineTargetUrl || null, wallOffers: a.wallOffers || 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(); + +// ---- JSON fallback ---- +const J = { + db: { v: 2, byEmail: {}, byAddress: {}, byCode: {}, joins: 0 }, + FILE: () => path.join(DATA_DIR, 'accounts.json'), + load() { + try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) {} + if (!this.db || !this.db.v) this.db = { v: 2, byEmail: {}, byAddress: {}, byCode: {}, joins: 0 }; + if (!this.db.byCode) this.db.byCode = {}; + if (!this.db.positions) this.db.positions = {}; + for (const a of Object.values(this.db.byEmail)) { + if (!a.code) { a.code = newCode(c => this.db.byCode[c]); this.db.byCode[a.code] = a.email; } + else if (!this.db.byCode[a.code]) this.db.byCode[a.code] = a.email; + } + }, + save() { + try { + const tmp = this.FILE() + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(this.db), { mode: 0o600 }); + fs.renameSync(tmp, this.FILE()); + } catch (e) { console.error('accounts save failed', e.message); } + }, + async signup(e, password, ref) { + if (this.db.byEmail[e]) return { error: 'That email already has an account. Log in instead.' }; + const code = newCode(c => this.db.byCode[c]); + this.db.byEmail[e] = { email: e, pass: hashPassword(password), sponsorRef: ref, code, address: null, created: Date.now() }; + this.db.byCode[code] = e; + this.save(); + return { ok: true, created: true, account: pub(this.db.byEmail[e]) }; + }, + async login(e, password) { + const a = this.db.byEmail[e]; + if (!a || !a.pass || !checkPassword(password, a.pass)) return { error: 'Wrong email or password.' }; + return { ok: true, account: pub(a) }; + }, + async ensure(e, ref, via, joinedRef) { + let created = false; + if (!this.db.byEmail[e]) { + const code = newCode(c => this.db.byCode[c]); + this.db.byEmail[e] = { email: e, pass: null, sponsorRef: ref, code, address: null, created: Date.now(), joinedVia: via || null, joinedRef: joinedRef || null }; + this.db.byCode[code] = e; + created = true; + this.save(); + } + return { ok: true, created, account: pub(this.db.byEmail[e]) }; + }, + async byEmail(e) { return pub(this.db.byEmail[e]); }, + async byAddress(a) { const e = this.db.byAddress[a]; return e ? pub(this.db.byEmail[e]) : null; }, + async byCode(c) { const e = this.db.byCode[c]; return e ? pub(this.db.byEmail[e]) : null; }, + async byUsername(u) { + for (const a of Object.values(this.db.byEmail)) if (a.username === u) return pub(a); + return null; + }, + async setUsername(e, u) { + const acct = this.db.byEmail[e]; + if (!acct) return { error: 'No such account.' }; + for (const a of Object.values(this.db.byEmail)) if (a.username === u && a.email !== e) + return { error: 'That username is taken. Try another.' }; + acct.username = u; + this.save(); + return { ok: true, account: pub(acct) }; + }, + async setLineBanner(e, bannerUrl, targetUrl) { + const acct = this.db.byEmail[e]; + if (!acct) return { error: 'No such account.' }; + acct.lineBannerUrl = bannerUrl || null; + acct.lineTargetUrl = targetUrl || null; + this.save(); + return { ok: true, account: pub(acct) }; + }, + async setWallOffers(e, json) { + const acct = this.db.byEmail[e]; + if (!acct) return { error: 'No such account.' }; + acct.wallOffers = json || null; this.save(); + return { ok: true, account: pub(acct) }; + }, + async setProfile(e, avatarUrl, bio, socials) { + const acct = this.db.byEmail[e]; + if (!acct) return { error: 'No such account.' }; + if (avatarUrl !== undefined) acct.avatarUrl = avatarUrl || null; + if (bio !== undefined) acct.bio = bio || null; + if (socials !== undefined) acct.socials = socials || null; + 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; + }, + async setMemberId(e, id) { + const acct = this.db.byEmail[e]; + if (acct && acct.memberId !== id) { acct.memberId = id; this.save(); } + }, + async namesForMembers(ids) { + const out = {}; + for (const a of Object.values(this.db.byEmail)) + if (a.username && a.memberId && ids.includes(a.memberId)) out[a.memberId] = a.username; + return out; + }, + async listByReferrer(refs) { + const set = new Set(refs.filter(Boolean).map(String)); + return Object.values(this.db.byEmail) + .filter(a => set.has(String(a.sponsorRef || ''))) + .sort((a, b) => b.created - a.created) + .slice(0, 200).map(pub); + }, + async linkWallet(e, a) { + const acct = this.db.byEmail[e]; + if (!acct) return { error: 'No such account.' }; + if (acct.address && acct.address !== a) return { error: 'This account is already linked to wallet ' + + acct.address.slice(0, 6) + '…' + acct.address.slice(-4) + '. Connect that wallet instead.' }; + if (this.db.byAddress[a] && this.db.byAddress[a] !== e) return { error: 'That wallet is already linked to a different account.' }; + if (this.db.positions[a]) return { error: 'That wallet is already a linked position' + (this.db.positions[a].email === e ? ' on this account.' : ' on a different account.') }; + acct.address = a; + this.db.byAddress[a] = e; + this.save(); + return { ok: true, account: pub(acct) }; + }, + async listAll(limit) { return Object.values(this.db.byEmail).sort((a, b) => (b.created || 0) - (a.created || 0)).slice(0, limit).map(pub); }, + async setSponsorRef(e, ref) { + const acct = this.db.byEmail[e]; + if (!acct) return { error: 'No such account.' }; + acct.sponsorRef = ref; this.save(); + return { ok: true, account: pub(acct) }; + }, + async count() { return Object.keys(this.db.byEmail).length; }, + // ---- linked positions (extra wallets on one account) ---- + async positions(e) { + return Object.entries(this.db.positions).filter(([, p]) => p.email === e) + .map(([address, p]) => ({ address, email: p.email, memberId: p.memberId || 0, created: p.created })) + .sort((x, y) => x.created - y.created); + }, + async addPosition(e, a) { + const acct = this.db.byEmail[e]; + if (!acct) return { error: 'No such account.' }; + if (!acct.address) return { error: 'Link your main wallet first.' }; + if (acct.address === a) return { error: 'That is your main wallet. Switch to a different account in your wallet app, then try again.' }; + if (this.db.byAddress[a]) return { error: 'That wallet is already the main wallet of another account.' }; + const cur = this.db.positions[a]; + if (cur && cur.email !== e) return { error: 'That wallet is already a position on a different account.' }; + if (!cur) { this.db.positions[a] = { email: e, memberId: 0, created: Date.now() }; this.save(); } + return { ok: true, address: a, created: !cur }; + }, + async setPositionMember(a, id) { const p = this.db.positions[a]; if (p && p.memberId !== id) { p.memberId = id; this.save(); } }, + async positionOwner(a) { const p = this.db.positions[a]; return p ? { address: a, email: p.email, memberId: p.memberId || 0 } : null; }, + async positionByMember(id) { const e = Object.entries(this.db.positions).find(([, p]) => p.memberId === Number(id)); return e ? { address: e[0], email: e[1].email, memberId: e[1].memberId } : null; }, + async removePosition(e, a) { + const p = this.db.positions[a]; + if (!p || p.email !== e) return { error: 'No such position.' }; + if (p.memberId) return { error: 'That position is already registered on-chain and cannot be unlinked.' }; + delete this.db.positions[a]; this.save(); return { ok: true }; + }, + // admin only: point the account at a different main wallet (or none); the caller re-reads the member id + async adminSetAddress(e, a) { + const acct = this.db.byEmail[e]; if (!acct) return { error: 'No such account.' }; + if (a) { + if (this.db.byAddress[a] && this.db.byAddress[a] !== e) return { error: 'That wallet is the main wallet of another account.' }; + if (this.db.positions[a]) return { error: 'That wallet is a linked position on ' + (this.db.positions[a].email === e ? 'this' : 'another') + ' account.' }; + } + if (acct.address) delete this.db.byAddress[acct.address]; + acct.address = a || null; if (a) this.db.byAddress[a] = e; this.save(); return { ok: true, account: pub(acct) }; + }, + async positionByAddress(a) { const p = this.db.positions[a]; return p ? { address: a, email: p.email, memberId: p.memberId } : null; }, + async removeAccount(e) { + const acct = this.db.byEmail[e]; if (!acct) return { error: 'No such account.' }; + if (acct.address) delete this.db.byAddress[acct.address]; if (acct.code) delete this.db.byCode[acct.code]; + for (const [a, p] of Object.entries(this.db.positions)) if (p.email === e) delete this.db.positions[a]; + delete this.db.byEmail[e]; this.save(); return { ok: true }; + }, +}; + +// ---- MySQL mode ---- +const rowPub = r => r ? pub({ email: r.email, sponsorRef: r.sponsor_ref, code: r.code, + username: r.username, memberId: r.member_id || 0, joinedVia: r.joined_via || null, joinedRef: r.joined_ref || null, + lineBannerUrl: r.line_banner_url, lineTargetUrl: r.line_target_url, wallOffers: r.wall_offers || null, + 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) { + const code = newCode(); + try { + await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created) VALUES (?,?,?,?,NULL,?)', + [e, hashPassword(password), ref, code, Date.now()]); + } catch (err) { + if (err.code === 'ER_DUP_ENTRY') return String(err.message).includes('code') + ? this.signup(e, password, ref) // code collision: retry with a new code + : { error: 'That email already has an account. Log in instead.' }; + throw err; + } + return { ok: true, created: true, account: await this.byEmail(e) }; + }, + async login(e, password) { + const rows = await db.q('SELECT * FROM accounts WHERE email=?', [e]); + if (!rows.length || !rows[0].pass || !checkPassword(password, rows[0].pass)) return { error: 'Wrong email or password.' }; + return { ok: true, account: rowPub(rows[0]) }; + }, + async ensure(e, ref, via, joinedRef) { + const code = newCode(); + let created = false; + try { + await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created,joined_via,joined_ref) VALUES (?,NULL,?,?,NULL,?,?,?)', + [e, ref, code, Date.now(), via || null, joinedRef || null]); + created = true; + } catch (err) { + if (err.code !== 'ER_DUP_ENTRY') throw err; + if (String(err.message).includes('code')) return this.ensure(e, ref); + } + return { ok: true, created, account: await this.byEmail(e) }; + }, + async byEmail(e) { const r = await db.q('SELECT * FROM accounts WHERE email=?', [e]); return rowPub(r[0]); }, + async byAddress(a) { const r = await db.q('SELECT * FROM accounts WHERE address=?', [a]); return rowPub(r[0]); }, + async byCode(c) { const r = await db.q('SELECT * FROM accounts WHERE code=?', [c]); return rowPub(r[0]); }, + async byUsername(u) { const r = await db.q('SELECT * FROM accounts WHERE username=?', [u]); return rowPub(r[0]); }, + async setUsername(e, u) { + try { await db.q('UPDATE accounts SET username=? WHERE email=?', [u, e]); } + catch (err) { + if (err.code === 'ER_DUP_ENTRY') return { error: 'That username is taken. Try another.' }; + throw err; + } + return { ok: true, account: await this.byEmail(e) }; + }, + async setLineBanner(e, bannerUrl, targetUrl) { + await db.q('UPDATE accounts SET line_banner_url=?, line_target_url=? WHERE email=?', [bannerUrl || null, targetUrl || null, e]); + return { ok: true, account: await this.byEmail(e) }; + }, + async setWallOffers(e, json) { + await db.q('UPDATE accounts SET wall_offers=? WHERE email=?', [json || null, e]); + return { ok: true, account: await this.byEmail(e) }; + }, + async setProfile(e, avatarUrl, bio, socials) { + if (avatarUrl !== undefined) await db.q('UPDATE accounts SET avatar_url=? WHERE email=?', [avatarUrl || null, e]); + if (bio !== undefined) await db.q('UPDATE accounts SET bio=? WHERE email=?', [bio || null, e]); + 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]); + }, + async setMemberId(e, id) { await db.q('UPDATE accounts SET member_id=? WHERE email=? AND (member_id IS NULL OR member_id<>?)', [id, e, id]); }, + async namesForMembers(ids) { + if (!ids.length) return {}; + const rows = await db.q('SELECT member_id, username FROM accounts WHERE username IS NOT NULL AND member_id IN (' + + ids.map(() => '?').join(',') + ')', ids); + const out = {}; + for (const r of rows) out[r.member_id] = r.username; + return out; + }, + async listByReferrer(refs) { + const clean = refs.filter(Boolean).map(String); + if (!clean.length) return []; + const rows = await db.q('SELECT * FROM accounts WHERE sponsor_ref IN (' + clean.map(() => '?').join(',') + + ') ORDER BY created DESC LIMIT 200', clean); + return rows.map(rowPub); + }, + async linkWallet(e, a) { + const cur = await this.byEmail(e); + if (!cur) return { error: 'No such account.' }; + if (cur.address && cur.address !== a) return { error: 'This account is already linked to wallet ' + + cur.address.slice(0, 6) + '…' + cur.address.slice(-4) + '. Connect that wallet instead.' }; + const pos = (await db.q('SELECT email FROM positions WHERE address=?', [a]))[0]; + if (pos) return { error: 'That wallet is already a linked position' + (pos.email === e ? ' on this account.' : ' on a different account.') }; + try { await db.q('UPDATE accounts SET address=? WHERE email=?', [a, e]); } + catch (err) { + if (err.code === 'ER_DUP_ENTRY') return { error: 'That wallet is already linked to a different account.' }; + throw err; + } + return { ok: true, account: await this.byEmail(e) }; + }, + async listAll(limit) { const rows = await db.q('SELECT * FROM accounts ORDER BY created DESC LIMIT ?', [Number(limit) || 500]); return rows.map(rowPub); }, + async setSponsorRef(e, ref) { + const r = await db.q('UPDATE accounts SET sponsor_ref=? WHERE email=?', [ref, e]); + if (!r.affectedRows) return { error: 'No such account.' }; + return { ok: true, account: await this.byEmail(e) }; + }, + async count() { const r = await db.q('SELECT COUNT(*) n FROM accounts'); return Number(r[0].n); }, + // ---- linked positions (extra wallets on one account) ---- + async positions(e) { + const rows = await db.q('SELECT * FROM positions WHERE email=? ORDER BY created', [e]); + return rows.map(r => ({ address: r.address, email: r.email, memberId: r.member_id || 0, created: Number(r.created) })); + }, + async addPosition(e, a) { + const acct = await this.byEmail(e); + if (!acct) return { error: 'No such account.' }; + if (!acct.address) return { error: 'Link your main wallet first.' }; + if (acct.address === a) return { error: 'That is your main wallet. Switch to a different account in your wallet app, then try again.' }; + if (await this.byAddress(a)) return { error: 'That wallet is already the main wallet of another account.' }; + const cur = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0]; + if (cur && cur.email !== e) return { error: 'That wallet is already a position on a different account.' }; + if (!cur) await db.q('INSERT INTO positions (address,email,member_id,created) VALUES (?,?,0,?)', [a, e, Date.now()]); + return { ok: true, address: a, created: !cur }; + }, + async setPositionMember(a, id) { await db.q('UPDATE positions SET member_id=? WHERE address=? AND member_id<>?', [id, a, id]); }, + async positionOwner(a) { + const r = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0]; + return r ? { address: r.address, email: r.email, memberId: r.member_id || 0 } : null; + }, + async positionByMember(id) { const r = await db.q('SELECT address, email, member_id FROM positions WHERE member_id=? LIMIT 1', [Number(id)]); return r[0] ? { address: r[0].address, email: r[0].email, memberId: r[0].member_id } : null; }, + async removePosition(e, a) { + const r = (await db.q('SELECT * FROM positions WHERE address=? AND email=?', [a, e]))[0]; + if (!r) return { error: 'No such position.' }; + if (r.member_id) return { error: 'That position is already registered on-chain and cannot be unlinked.' }; + await db.q('DELETE FROM positions WHERE address=?', [a]); return { ok: true }; + }, + async adminSetAddress(e, a) { + if (a) { + const o = (await db.q('SELECT email FROM accounts WHERE address=?', [a]))[0]; if (o && o.email !== e) return { error: 'That wallet is the main wallet of another account.' }; + const pos = (await db.q('SELECT email FROM positions WHERE address=?', [a]))[0]; if (pos) return { error: 'That wallet is a linked position on ' + (pos.email === e ? 'this' : 'another') + ' account.' }; + } + await db.q('UPDATE accounts SET address=? WHERE email=?', [a || null, e]); return { ok: true, account: await this.byEmail(e) }; + }, + async positionByAddress(a) { const r = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0]; return r ? { address: r.address, email: r.email, memberId: r.member_id || 0 } : null; }, + async removeAccount(e) { + await db.q('DELETE FROM positions WHERE email=?', [e]); + const r = await db.q('DELETE FROM accounts WHERE email=?', [e]); return r.affectedRows ? { ok: true } : { error: 'No such account.' }; + }, +}; + +const impl = () => db.enabled() ? D : J; +function init(opts) { DATA_DIR = opts.dataDir; J.load(); } + +async function signup(email, password, sponsorRef) { + const e = normEmail(email); + if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' }; + if (String(password || '').length < 8) return { error: 'Password needs at least 8 characters.' }; + return impl().signup(e, String(password), String(sponsorRef || '')); +} +async function login(email, password) { return impl().login(normEmail(email), String(password || '')); } +async function ensure(email, sponsorRef, via, joinedRef) { + const e = normEmail(email); + if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' }; + return impl().ensure(e, String(sponsorRef || ''), String(via || '').toLowerCase().slice(0, 20) || null, String(joinedRef || '').toLowerCase().slice(0, 80) || null); +} +async function byEmail(email) { return impl().byEmail(normEmail(email)); } +async function byAddress(address) { return impl().byAddress(normAddr(address)); } +async function byCode(code) { return impl().byCode(String(code || '').toLowerCase()); } +async function byUsername(u) { + const n = normUser(u); + return USER_RE.test(n) ? impl().byUsername(n) : null; +} +async function setUsername(email, username) { + const n = normUser(username); + if (!USER_RE.test(n)) return { error: 'Usernames are 3 to 20 letters, numbers, or underscores.' }; + if (/^\d+$/.test(n)) return { error: 'Usernames need at least one letter.' }; // keep /join/ unambiguous + return impl().setUsername(normEmail(email), n); +} +async function setMemberId(email, id) { return impl().setMemberId(normEmail(email), Number(id) || 0); } +async function namesForMembers(ids) { return impl().namesForMembers([...new Set(ids)].filter(n => n > 0)); } +async function listByReferrer(refs) { return impl().listByReferrer(refs || []); } +// 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.' }; + return impl().linkWallet(normEmail(email), a); +} +async function count() { return impl().count(); } +// admin: newest-first account list, and re-pointing a member's sponsor (a +// username, share code, or numeric member id: the same tokens join links use) +async function listAll(limit = 500) { return impl().listAll(limit); } +async function setSponsorRef(email, ref) { return impl().setSponsorRef(normEmail(email), String(ref || '').trim().toLowerCase().slice(0, 40)); } + +// 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, byMemberId: id => impl().byMemberId(Number(id) || 0), listAll, setSponsorRef, + setUsername, setMemberId, namesForMembers, listByReferrer, downline, linkWallet, count, + setLineBanner: (e, b, t) => impl().setLineBanner(String(e || '').toLowerCase(), b, t), + setWallOffers: (e, j) => impl().setWallOffers(String(e || '').toLowerCase(), j), + setProfile: (e, a, bio, socials) => impl().setProfile(String(e || '').toLowerCase(), a, bio, socials), + touchSeen: e => impl().touchSeen(String(e || '').toLowerCase()), + adminSetAddress: (e, a) => impl().adminSetAddress(String(e || '').toLowerCase(), a ? normAddr(a) : null), + positionByAddress: a => impl().positionByAddress(normAddr(a)), + removeAccount: e => impl().removeAccount(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, + positions: e => impl().positions(normEmail(e)), + addPosition: (e, a) => { const x = normAddr(a); return /^0x[0-9a-f]{40}$/.test(x) ? impl().addPosition(normEmail(e), x) : Promise.resolve({ error: 'Bad wallet address.' }); }, + setPositionMember: (a, id) => impl().setPositionMember(normAddr(a), Number(id) || 0), + positionOwner: a => impl().positionOwner(normAddr(a)), + positionByMember: id => impl().positionByMember(Number(id) || 0), + removePosition: (e, a) => impl().removePosition(normEmail(e), normAddr(a)), + byMemberId: id => impl().byMemberId(id) }; diff --git a/adminmember.js b/adminmember.js new file mode 100644 index 0000000..aca5586 --- /dev/null +++ b/adminmember.js @@ -0,0 +1,109 @@ +// Admin member card (Marty, 2026-09-13): one lookup that gathers everything known about a member so +// the admin can search, drill down and act without digging through pages. Read side only; the +// edits live in server.js (/api/admin/member PATCH/DELETE) and accounts.js. +const fs = require('fs'); +const path = require('path'); +const db = require('./db'); +let R = null; // { accounts, ads, chain, tank, legacy, promos, messages, dataDir } +function init(refs) { R = refs; } + +const nameOf = a => a ? (a.username ? '@' + a.username : a.memberId ? 'member #' + a.memberId : a.email) : null; + +// resolve a search token: email, @username, member #, share code, or wallet address +async function resolve(q) { + const { accounts } = R; + let t = String(q || '').trim(); if (!t) return null; + if (t.includes('@') && t.indexOf('@') > 0) { const a = await accounts.byEmail(t.toLowerCase()); if (a) return a; } + const u = t.replace(/^@/, ''); + if (/^0x[0-9a-f]{40}$/i.test(t)) { + const a = await accounts.byAddress(t.toLowerCase()); if (a) return a; + const pos = accounts.positionByAddress ? await accounts.positionByAddress(t.toLowerCase()) : null; + if (pos && pos.email) return accounts.byEmail(pos.email); + } + if (/^#?\d+$/.test(t)) { + const id = Number(t.replace('#', '')); + const a = await accounts.byMemberId(id); if (a) return a; + const pos = await accounts.positionByMember(id); if (pos && pos.email) return accounts.byEmail(pos.email); + } + let a = await accounts.byUsername(u.toLowerCase()); if (a) return a; + a = await accounts.byCode(u.toLowerCase()); if (a) return a; + return null; +} + +async function view(email) { + const { accounts, ads, chain, tank, legacy, promos, messages } = R; + const acct = await accounts.byEmail(String(email || '').toLowerCase()); + if (!acct) return null; + const out = { account: acct }; + // sponsor + upline chain (site-side sponsorship, up to 5 levels) + const up = []; let cur = acct; const seen = new Set([acct.email]); + for (let i = 0; i < 5 && cur; i++) { + const s = await accounts.sponsorOf(cur.email).catch(() => null); + if (!s || seen.has(s.email)) break; seen.add(s.email); + up.push({ email: s.email, name: nameOf(s), memberId: s.memberId || 0 }); cur = s; + } + out.upline = up; out.sponsorName = up[0] ? up[0].name : null; + // positions (extra wallets) + every on-chain id this account owns + const positions = await accounts.positions(acct.email).catch(() => []); + out.positions = positions; + const ids = new Set([acct.memberId, ...positions.map(p => p.memberId)].filter(Boolean)); + out.ids = [...ids]; + // on-chain + out.chain = null; + if (acct.memberId) { + try { const m = await chain.member(acct.memberId); out.chain = { memberId: acct.memberId, sponsorId: m.sponsorId, buyerCount: m.buyerCount, activated: m.activated, account: m.account, + level: m.buyerCount >= 5 ? 3 : m.buyerCount >= 2 ? 2 : 1 }; } catch (e) { out.chain = { memberId: acct.memberId, readError: true }; } + } + const purchases = [], received = [], qualified = new Set(); let receivedWei = 0n, spentWei = 0n, spentCents = 0; + if (ids.size) { + for (const ev of chain.recentEvents(1e9)) { + if (ev.type === 'Purchase' && ids.has(ev.buyerId)) { purchases.push({ ts: ev.ts, buyerId: ev.buyerId, priceCents: ev.priceCents, paidWei: ev.paidWei, credits: ev.creditAmount, tx: ev.tx }); spentWei += BigInt(ev.paidWei || 0); spentCents += Number(ev.priceCents || 0); } + if (ev.type === 'TierPaid' && ids.has(ev.recipientId)) { received.push({ ts: ev.ts, buyerId: ev.buyerId, tier: ev.tier, amountWei: ev.amountWei, tx: ev.tx }); receivedWei += BigInt(ev.amountWei || 0); } + if (ev.type === 'BuyerCounted' && ids.has(ev.sponsorId) && ev.newBuyerId) qualified.add(ev.newBuyerId); + } + } + purchases.sort((a, b) => b.ts - a.ts); received.sort((a, b) => b.ts - a.ts); + out.purchases = purchases; out.received = received.slice(0, 50); + out.totals = { purchases: purchases.length, spentCents, spentWei: spentWei.toString(), receivedWei: receivedWei.toString(), payoutsIn: received.length }; + const buyerNames = await accounts.namesForMembers([...new Set([...purchases.map(p => p.buyerId), ...received.map(r => r.buyerId)])]).catch(() => ({})); + out.names = buyerNames; + // credits + try { out.credits = await ads.balances([...ids], acct.email); } catch (e) { out.credits = null; } + try { out.earnedSplit = await ads.earnedSplit(acct.email); } catch (e) {} + // campaigns + try { out.campaigns = (await ads.listCampaigns(acct.email)).map(c => ({ id: c.id, type: c.type, status: c.status, name: c.name || c.title || '', budget: c.budget, spent: c.spent, created: c.created, views: c.views, clicks: c.clicks })); } catch (e) { out.campaigns = []; } + // line (3 levels) with wallet / bought / qualified per person + const levels = await accounts.downline(acct.email, 3).catch(() => []); + const boughtIds = new Set(chain.recentEvents(1e9).filter(ev => ev.type === 'Purchase').map(ev => ev.buyerId)); + out.line = levels.map(L => ({ level: L.level, members: L.members.map(m => ({ email: m.email, name: nameOf(m), username: m.username, memberId: m.memberId || 0, wallet: !!m.address, joined: m.created, lastSeen: m.lastSeen || 0, + bought: !!(m.memberId && boughtIds.has(m.memberId)), qualified: !!(m.memberId && qualified.has(m.memberId)) })) })); + out.lineCounts = out.line.map(L => L.members.length); + // tank / adoptions + try { + const tv = await tank.adminView(); + out.tank = { waiting: !!tv.waiting.find(w => w.email === acct.email), + adoptedBy: tv.adoptions.filter(a => a.adoptee === acct.email).map(a => ({ name: a.adopterName, email: a.adopter, created: a.created, status: a.status || a.state || '' })), + adopted: tv.adoptions.filter(a => a.adopter === acct.email).map(a => ({ name: a.adopteeName, email: a.adoptee, created: a.created, status: a.status || a.state || '' })) }; + } catch (e) { out.tank = null; } + // legacy + promo + drip + earning + messages + try { const rec = legacy.lookup(acct.email); let g = null; try { g = JSON.parse(fs.readFileSync(path.join(R.dataDir, 'legacy-grants.json'), 'utf8'))[acct.email] || null; } catch (e) {} out.legacy = rec ? { brand: rec.b, seg: rec.s === 'a' ? 'advertiser' : 'earner', grant: g } : null; } catch (e) { out.legacy = null; } + try { + if (db.enabled()) out.promos = await db.q('SELECT code, credits, via, ts FROM promo_redemptions WHERE email=? ORDER BY ts DESC', [acct.email]); + else out.promos = (await promos.adminView()).recent.filter(r => r.email === acct.email); + } catch (e) { out.promos = []; } + try { out.drip = db.enabled() ? (await db.q('SELECT step, next_at, started, stopped, ref, angle FROM drips WHERE email=?', [acct.email]))[0] || null : null; } catch (e) { out.drip = null; } + try { const vs = await ads.viewStatus(acct.email); out.earning = { today: vs.views || vs.viewsToday || 0, claimed: !!vs.claimed, streakDay: vs.streakDay || 0 }; } catch (e) { out.earning = null; } + try { + if (db.enabled()) { + const since = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 10); + const dv = await db.q('SELECT day, views, claimed, video_count FROM daily_views WHERE email=? AND day>=? ORDER BY day DESC', [acct.email, since]); + out.days = dv; out.activeDays14 = dv.filter(d => d.views > 0 || d.video_count > 0).length; out.claims14 = dv.filter(d => d.claimed).length; + const mc = await db.q('SELECT COUNT(*) n FROM sponsor_messages WHERE from_email=? OR to_email=?', [acct.email, acct.email]); out.messageCount = Number(mc[0].n) || 0; + const vv = await db.q('SELECT COUNT(*) n FROM visit_seen WHERE email=?', [acct.email]).catch(() => [{ n: 0 }]); out.visits = Number(vv[0].n) || 0; + const vw = await db.q('SELECT COUNT(*) n FROM video_seen WHERE email=?', [acct.email]).catch(() => [{ n: 0 }]); out.videos = Number(vw[0].n) || 0; + } + } catch (e) {} + return out; +} + +module.exports = { init, resolve, view, nameOf }; diff --git a/ads.js b/ads.js new file mode 100644 index 0000000..2960a3c Binary files /dev/null and b/ads.js differ diff --git a/audit.js b/audit.js new file mode 100644 index 0000000..09a41ee --- /dev/null +++ b/audit.js @@ -0,0 +1,72 @@ +// Counter audit (Marty, 2026-09-15: "buyers will lose confidence if they feel cheated"). Every ad format's +// recorded impressions are reconciled against the log that proves delivery, and login/featured charging is +// checked against actual shows. Runs on demand from Admin > Reports and once a day; any issue alerts the admin. +// DB mode only (production); JSON mode reports "not checked". +const db = require('./db'); +const fs = require('fs'); const path = require('path'); +let R = null; // { notify(text), dataDir } +function init(refs) { R = refs; } +// credits already returned for past counting errors: subtract them so history that was made right does not keep flagging +function refunded() { const out = {}; try { for (const f of fs.readdirSync(R.dataDir)) if (/^refunds-.*\.json$/.test(f)) for (const i of (JSON.parse(fs.readFileSync(path.join(R.dataDir, f), 'utf8')).items || [])) out[i.id] = (out[i.id] || 0) + Number(i.credits || 0); } catch (e) {} return out; } + +async function run() { + const out = { checkedAt: Date.now(), checks: [] }; + if (!db.enabled()) { out.checks.push({ name: 'Counters', ok: true, detail: 'JSON mode: not checked', issues: [] }); return out; } + const camps = await db.q("SELECT id, name, type, status, imps, clicks, spent, accrued, budget, created, house FROM campaigns WHERE house=0 OR house IS NULL"); + const back = refunded(); for (const c of camps) if (back[c.id]) c.spent = Math.max(0, Number(c.spent) - back[c.id]); // already made right + const logStart = (await db.q('SELECT MIN(day) d FROM camp_hours'))[0].d; const logStartMs = logStart ? Date.parse(logStart + 'T00:00:00Z') : 0; + const byType = t => camps.filter(c => c.type === t); + const add = (name, issues, detail) => out.checks.push({ name, ok: !issues.length, detail, issues }); + // 1. per-view formats: campaign.imps must equal the rows in the log that paid for them + for (const [type, table, label] of [['video', 'video_seen', 'completed watches'], ['visits', 'visit_seen', 'verified visits'], ['solo', 'solo_inbox', 'inbox deliveries']]) { + const rows = await db.q('SELECT campaign_id, COUNT(*) n FROM ' + table + ' GROUP BY campaign_id'); const logN = {}; for (const r of rows) logN[r.campaign_id] = Number(r.n); + const issues = []; let tot = 0, logTot = 0; + for (const c of byType(type)) { const l = logN[c.id] || 0; tot += Number(c.imps); logTot += l; if (Number(c.imps) !== l) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': ' + c.imps + ' views vs ' + l + ' ' + label); } + add(type + ' views vs ' + label, issues, tot + ' views recorded, ' + logTot + ' ' + label); + } + // 2. banner/text: imps must equal the hourly log (both bumped on serve) + { + const rows = await db.q('SELECT campaign_id, SUM(n) n FROM camp_hours GROUP BY campaign_id'); const h = {}; for (const r of rows) h[r.campaign_id] = Number(r.n); + const issues = []; + // only campaigns that started after the hourly log did + for (const c of camps.filter(c => ['banner', 'text'].includes(c.type) && Number(c.created) >= logStartMs)) { const l = h[c.id] || 0; if (Math.abs(Number(c.imps) - l) > Math.max(5, Math.round(0.02 * Math.max(Number(c.imps), l)))) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': ' + c.imps + ' views vs ' + l + ' in the hourly log'); } + add('banner/text views vs hourly log', issues, camps.filter(c => ['banner', 'text'].includes(c.type) && Number(c.created) >= logStartMs).length + ' campaigns compared (started since the hourly log began ' + (logStart || '') + ')'); + } + // 3. login: days charged vs days shown (a day may only be charged after the ad was shown) + { + const rows = await db.q('SELECT campaign_id, COUNT(DISTINCT day) d FROM camp_hours WHERE n>0 GROUP BY campaign_id'); const shown = {}; for (const r of rows) shown[r.campaign_id] = Number(r.d); + const issues = []; + for (const c of byType('login')) { const charged = Math.round((Number(c.spent) + Number(c.accrued || 0)) / 100), s = shown[c.id] || 0; if (charged > s) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': charged ' + charged + ' day(s), shown on ' + s); } + add('login ads: days charged vs days shown', issues, byType('login').length + ' campaigns compared'); + } + // 4. featured: a live booking older than two hours must have views + { + const issues = []; const now = Date.now(); + for (const c of byType('featured').filter(c => c.status === 'active' && now - Number(c.created) > 2 * 3600e3)) if (!Number(c.imps)) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': live with 0 views'); + add('featured: live bookings have views', issues, byType('featured').filter(c => c.status === 'active').length + ' live bookings'); + } + // 5. clicks can only exceed views where a click precedes the counted view (visits, login) + { + const issues = []; + for (const c of camps.filter(c => !['visits', 'login'].includes(c.type) && Number(c.clicks) > Number(c.imps) + 5)) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ' (' + c.type + '): ' + c.clicks + ' clicks vs ' + c.imps + ' views'); + add('clicks vs views', issues, 'campaigns with more clicks than views, where that cannot happen'); + } + // 6. budgets: spent can never pass budget + { + const issues = camps.filter(c => Number(c.spent) + Number(c.accrued || 0) > Number(c.budget) + 1).map(c => '#' + c.id + ' ' + c.name.slice(0, 30) + ': spent ' + (Number(c.spent) + Number(c.accrued || 0)) + ' of ' + c.budget); + add('spend never exceeds budget', issues, camps.length + ' campaigns'); + } + return out; +} +// the "once a day" memory lives on the volume: a redeploy restarts the process, and Marty got an alert after every deploy (2026-09-15) +const STATE = () => path.join(R.dataDir, 'audit-state.json'); +function lastAlertDay() { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')).lastAlertDay || ''; } catch (e) { return ''; } } +function setAlertDay(d) { try { fs.writeFileSync(STATE(), JSON.stringify({ lastAlertDay: d })); } catch (e) {} } +async function dailyTick() { + try { + const r = await run(); const bad = r.checks.filter(c => !c.ok); const day = new Date().toISOString().slice(0, 10); + if (bad.length && lastAlertDay() !== day && R && R.notify) { setAlertDay(day); R.notify('⚠️ LinkSpin counter audit found ' + bad.length + ' issue' + (bad.length === 1 ? '' : 's') + ': ' + bad.map(c => c.name + ' (' + c.issues.length + ')').join('; ') + '. Admin > Reports > Counters audit.'); } + return r; + } catch (e) { console.error('audit', e.message); return null; } +} +module.exports = { init, run, dailyTick }; diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..eb40d39 --- /dev/null +++ b/auth.js @@ -0,0 +1,169 @@ +// Wallet sign-in (SIWE / EIP-4361) + session store for LinkSpin. +// Sessions are dual-mode like accounts.js: MySQL when DATABASE_URL is set, +// volume JSON otherwise. All session functions are async. +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { keccak256 } = require('./vendor/sha3'); +const secp = require('./vendor/secp256k1'); +const db = require('./db'); + +let DATA_DIR = null; +let chain = null; +let IS_PROD = false; +let SITE = 'linkspin-test.saasy.top'; + +const CHALLENGE_TTL = 10 * 60 * 1000; +const SESSION_TTL = 24 * 60 * 60 * 1000; // 24h — forces a daily re-login so the login ad shows each day +const challenges = new Map(); // addressLower -> {message, exp} + +// ---- JSON session fallback ---- +const J = { + sessions: new Map(), + FILE: () => path.join(DATA_DIR, 'sessions.json'), + load() { + try { + const o = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); + this.sessions = new Map(Object.entries(o).filter(([, s]) => s.expires > Date.now())); + } catch (e) { this.sessions = new Map(); } + }, + save() { + try { + const tmp = this.FILE() + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.sessions)), { mode: 0o600 }); + fs.renameSync(tmp, this.FILE()); + } catch (e) { console.error('session save failed', e.message); } + }, + async mint(fields) { + const token = crypto.randomBytes(32).toString('hex'); + this.sessions.set(token, Object.assign({ email: null, address: null, memberId: 0 }, fields, + { expires: Date.now() + SESSION_TTL })); + this.save(); + return token; + }, + async get(token) { + const s = this.sessions.get(token); + if (!s || s.expires < Date.now()) return null; + return Object.assign({ token }, s); + }, + async update(token, fields) { + const s = this.sessions.get(token); + if (!s) return; + this.sessions.set(token, Object.assign({}, s, fields)); + this.save(); + }, + async drop(token) { this.sessions.delete(token); this.save(); } +}; + +// ---- MySQL session mode ---- +const D = { + async mint(fields) { + const token = crypto.randomBytes(32).toString('hex'); + await db.q('INSERT INTO sessions (token,email,address,member_id,expires) VALUES (?,?,?,?,?)', + [token, fields.email || null, fields.address || null, fields.memberId || 0, Date.now() + SESSION_TTL]); + return token; + }, + async get(token) { + const rows = await db.q('SELECT * FROM sessions WHERE token=? AND expires>?', [token, Date.now()]); + if (!rows.length) return null; + const r = rows[0]; + return { token, email: r.email, address: r.address, memberId: r.member_id, expires: Number(r.expires) }; + }, + async update(token, fields) { + const sets = [], vals = []; + if ('email' in fields) { sets.push('email=?'); vals.push(fields.email); } + if ('address' in fields) { sets.push('address=?'); vals.push(fields.address); } + if ('memberId' in fields) { sets.push('member_id=?'); vals.push(fields.memberId); } + if (!sets.length) return; + vals.push(token); + await db.q('UPDATE sessions SET ' + sets.join(',') + ' WHERE token=?', vals); + }, + async drop(token) { await db.q('DELETE FROM sessions WHERE token=?', [token]); } +}; +const impl = () => db.enabled() ? D : J; + +function init(opts) { + DATA_DIR = opts.dataDir; chain = opts.chain; IS_PROD = !!opts.isProd; + if (opts.site) SITE = opts.site; + J.load(); +} + +// ---- crypto ---- +function personalDigest(msg) { + const m = Buffer.from(msg, 'utf8'); + const pre = Buffer.from('\x19Ethereum Signed Message:\n' + m.length, 'utf8'); + return Buffer.from(keccak256(Buffer.concat([pre, m])), 'hex'); +} +function recoverAddress(msg, signature) { + const raw = Buffer.from(String(signature).replace(/^0x/, ''), 'hex'); + if (raw.length !== 65) throw new Error('Bad signature length'); + let v = raw[64]; if (v >= 27) v -= 27; + if (v !== 0 && v !== 1) throw new Error('Bad signature recovery byte'); + const pub = secp.recoverPublicKey(personalDigest(msg), raw.slice(0, 64), v, false); + return '0x' + keccak256(Buffer.from(pub.slice(1))).slice(-40); +} +const ADDR_RE = /^0x[0-9a-fA-F]{40}$/; +function checksumAddress(address) { + const a = address.toLowerCase().replace(/^0x/, ''); + const h = keccak256(a); + let out = '0x'; + for (let i = 0; i < a.length; i++) out += parseInt(h[i], 16) >= 8 ? a[i].toUpperCase() : a[i]; + return out; +} + +// ---- SIWE flow ---- +function makeChallenge(address) { + if (!ADDR_RE.test(address || '')) return { error: 'Bad address' }; + const a = address.toLowerCase(); + const nonce = crypto.randomBytes(16).toString('hex'); + const chainId = chain.getConfig().chainId; + const message = `${SITE} wants you to sign in with your Ethereum account:\n${checksumAddress(a)}\n\nLinkSpin member sign-in. This signature is free and cannot move funds or approve anything.\n\nURI: https://${SITE}\nVersion: 1\nChain ID: ${chainId}\nNonce: ${nonce}\nIssued At: ${new Date().toISOString()}`; + challenges.set(a, { message, exp: Date.now() + CHALLENGE_TTL }); + return { message }; +} +async function verifyChallenge(address, signature) { + const a = (address || '').toLowerCase(); + const ch = challenges.get(a); + if (!ch || ch.exp < Date.now()) return { error: 'Challenge expired - tap sign-in again.' }; + let rec; + try { rec = recoverAddress(ch.message, signature); } catch (e) { return { error: 'Invalid signature: ' + e.message }; } + if (rec !== a) return { error: 'Your wallet signed with a different account than the page is using (' + + rec.slice(0, 6) + '…' + rec.slice(-4) + '). Switch accounts and tap sign-in again.' }; + challenges.delete(a); + return { ok: true, address: a }; +} + +// ---- sessions ---- +async function mintSession(fields) { return impl().mint(fields || {}); } +async function updateSession(token, fields) { return impl().update(token, fields || {}); } +function sessionCookie(token) { + return `iap.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}${IS_PROD ? '; Secure' : ''}`; +} +function clearCookie() { return 'iap.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; } +async function fromRequest(req) { + const m = /(?:^|;\s*)iap\.sid=([^;]+)/.exec(req.headers.cookie || ''); + if (!m) return null; + return impl().get(decodeURIComponent(m[1])); +} +async function refreshMemberId(sess) { + if (!sess) return 0; + if (!sess.address) { + if (sess.memberId) return sess.memberId; + // email-only session (phone sign-in with no wallet connected): read the account's + // on-chain id instead, so read-only views (My line, earnings, P&L) work from any + // device. Not cached into the session: a session without an address stays wallet-less. + if (sess.email) { try { const a = await require('./accounts').byEmail(sess.email); if (a && a.memberId) return a.memberId; } catch (e) {} } + return 0; + } + try { + const id = await chain.memberIdByAccount(sess.address); + if (id && id !== sess.memberId) await updateSession(sess.token, { memberId: id }); + return id || sess.memberId || 0; + } catch (e) { return sess.memberId || 0; } +} +async function logout(req) { + const s = await fromRequest(req); + if (s) await impl().drop(s.token); +} + +module.exports = { init, makeChallenge, verifyChallenge, mintSession, updateSession, sessionCookie, clearCookie, fromRequest, refreshMemberId, logout }; diff --git a/blog.js b/blog.js new file mode 100644 index 0000000..c2c40b6 --- /dev/null +++ b/blog.js @@ -0,0 +1,162 @@ +// Public blog (Marty, 2026-09-12): coaching and teaching articles on linkspin-test.saasy.top, written in +// Admin > Blog, server-rendered so crawlers see real HTML with real metadata. Storage: MySQL +// blog_posts, or DATA_DIR/blog.json. Public: /blog (paged index), /blog/, /blog/feed.xml, +// /sitemap.xml, /robots.txt. SEO per post: title, description, canonical, Open Graph, Twitter +// card, article dates, BlogPosting JSON-LD, breadcrumb JSON-LD, internal links, related posts. +const fs = require('fs'); +const path = require('path'); +const db = require('./db'); +let DATA_DIR = null; +const SITE = 'https://linkspin-test.saasy.top'; +const AUTHOR = 'Marty Bostick'; +const PER_PAGE = 10; + +const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); +const slugify = s => String(s || '').toLowerCase().replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80); +const words = html => String(html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); +const readMinutes = html => Math.max(1, Math.round(words(html).split(' ').length / 220)); +const fmtDate = ts => new Date(ts).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Chicago' }); + +// article HTML from the admin editor: keep a small whitelist of tags, http(s) links and safe images +function sanitize(html) { + const src = String(html || '').replace(/ /g, ' ').replace(/<(script|style|iframe|object|embed|form)\b[\s\S]*?<\/\1\s*>/gi, '').replace(/<(script|style|iframe|object|embed|form|input)\b[^>]*>/gi, '').replace(//g, '').replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ''); + const ALLOW = new Set(['p', 'br', 'hr', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'b', 'strong', 'i', 'em', 'u', 'a', 'img', 'blockquote', 'pre', 'code', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tr', 'th', 'td']); + const safeSrc = s => /^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|\/banners\/[a-z0-9._-]+\.(png|jpg|webp|gif)|https:\/\/[^\s"'<>]+)$/i.test(s); + return src.replace(/<\s*(\/?)\s*([a-zA-Z0-9]+)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (m, close, tag, attrs) => { + tag = tag.toLowerCase(); + if (!ALLOW.has(tag)) return ''; + if (close) return ''; + if (tag === 'a') { + const hm = /href\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs || ''); const href = (hm && (hm[1] || hm[2])) || ''; + if (!/^(https?:\/\/|\/)[^\s"'<>]*$/i.test(href)) return ''; + const internal = href.startsWith('/') || href.startsWith(SITE); + return ''; + } + if (tag === 'img') { + const sm = /src\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs || ''); const s = (sm && (sm[1] || sm[2])) || ''; + const am = /alt\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs || ''); const alt = (am && (am[1] || am[2])) || ''; + return safeSrc(s) ? '' + esc(alt) + '' : ''; + } + if (tag === 'br' || tag === 'hr') return '<' + tag + '>'; + return '<' + tag + '>'; + }); +} + +// ---- storage ---- +const J = { + db: { v: 1, posts: {} }, + FILE() { return path.join(DATA_DIR, 'blog.json'); }, + load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} }, + save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }, + async all() { return Object.values(this.db.posts); }, + async get(slug) { return this.db.posts[slug] || null; }, + async put(p) { this.db.posts[p.slug] = p; this.save(); return p; }, + async remove(slug) { delete this.db.posts[slug]; this.save(); }, + async bumpViews(slug) { const p = this.db.posts[slug]; if (p) { p.views = (p.views || 0) + 1; this.save(); } } +}; +const D = { + async all() { return (await db.q('SELECT * FROM blog_posts ORDER BY COALESCE(published_at, created) DESC')).map(row); }, + async get(slug) { const r = await db.q('SELECT * FROM blog_posts WHERE slug=?', [slug]); return r[0] ? row(r[0]) : null; }, + async put(p) { + await db.q(`INSERT INTO blog_posts (slug,title,excerpt,body,cover,tags,status,author,created,updated,published_at,views) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE title=VALUES(title), excerpt=VALUES(excerpt), body=VALUES(body), cover=VALUES(cover), tags=VALUES(tags), status=VALUES(status), author=VALUES(author), updated=VALUES(updated), published_at=VALUES(published_at)`, + [p.slug, p.title, p.excerpt, p.body, p.cover || null, (p.tags || []).join(','), p.status, p.author, p.created, p.updated, p.publishedAt || null, p.views || 0]); + return this.get(p.slug); + }, + async remove(slug) { await db.q('DELETE FROM blog_posts WHERE slug=?', [slug]); }, + async bumpViews(slug) { await db.q('UPDATE blog_posts SET views=views+1 WHERE slug=?', [slug]); } +}; +const row = r => ({ slug: r.slug, title: r.title, excerpt: r.excerpt || '', body: r.body || '', cover: r.cover || '', tags: r.tags ? String(r.tags).split(',').filter(Boolean) : [], status: r.status, author: r.author || AUTHOR, created: Number(r.created), updated: Number(r.updated), publishedAt: r.published_at ? Number(r.published_at) : null, views: Number(r.views) || 0 }); +const impl = () => db.enabled() ? D : J; + +function init(opts) { DATA_DIR = opts.dataDir; if (!db.enabled()) J.load(); } +async function listAll() { return (await impl().all()).sort((a, b) => (b.publishedAt || b.created) - (a.publishedAt || a.created)); } +async function listPublished() { return (await listAll()).filter(p => p.status === 'published' && (p.publishedAt || 0) <= Date.now()); } +async function get(slug) { return impl().get(slugify(slug)); } +async function save(input, existingSlug) { + const title = String(input.title || '').trim().slice(0, 140); + if (!title) return { error: 'Give the post a title.' }; + let slug = slugify(input.slug || title); if (!slug) return { error: 'Slug needs letters or numbers.' }; + const prev = existingSlug ? await impl().get(existingSlug) : null; + if (!prev && await impl().get(slug)) return { error: 'That slug is already used. Pick another.' }; + if (prev && prev.slug !== slug) { if (await impl().get(slug)) return { error: 'That slug is already used.' }; await impl().remove(prev.slug); } + const now = Date.now(); + const status = input.status === 'published' ? 'published' : 'draft'; + const body = sanitize(input.body); + const excerpt = String(input.excerpt || '').trim().slice(0, 300) || words(body).slice(0, 200); + const post = { slug, title, excerpt, body, cover: /^(\/uploads\/|\/banners\/|https:\/\/)/.test(String(input.cover || '')) ? String(input.cover).slice(0, 300) : '', tags: String(input.tags || '').split(',').map(t => t.trim().toLowerCase()).filter(Boolean).slice(0, 8), + status, author: AUTHOR, created: prev ? prev.created : now, updated: now, + publishedAt: status === 'published' ? (prev && prev.publishedAt ? prev.publishedAt : (input.publishedAt ? Number(new Date(input.publishedAt)) || now : now)) : (prev ? prev.publishedAt : null), views: prev ? prev.views : 0 }; + return { ok: true, post: await impl().put(post) }; +} +async function remove(slug) { await impl().remove(slugify(slug)); return { ok: true }; } +async function bumpViews(slug) { try { await impl().bumpViews(slug); } catch (e) {} } + +// ---- rendering ---- +function head(o) { + const url = SITE + o.path; + const img = o.image ? (o.image.startsWith('http') ? o.image : SITE + o.image) : SITE + '/banners/iap-hero-1200x630.png'; + return '' + + '' + esc(o.title) + '' + + '' + + '' + + (o.article ? '' + (o.article.tags || []).map(t => '').join('') : '') + + '' + + '' + + '' + + '' + + '
'; +} +function tail() { + return '
'; +} +const authorBox = () => '
LinkSpin
' + AUTHOR + 'Founder of LinkSpin and the Crypto Team Build Network. Twenty-plus years of internet marketing, and a habit of writing down what actually worked.
'; +const cardHtml = p => '
' + (p.cover ? '' : '') + '

' + esc(p.title) + '

' + esc(p.excerpt) + '

' + fmtDate(p.publishedAt || p.created) + ' · ' + readMinutes(p.body) + ' min read' + (p.tags.length ? ' · ' + p.tags.map(esc).join(', ') : '') + '

'; + +function renderIndex(posts, page, tag) { + const all = tag ? posts.filter(p => p.tags.includes(tag)) : posts; + const pages = Math.max(1, Math.ceil(all.length / PER_PAGE)); page = Math.min(Math.max(1, page || 1), pages); + const slice = all.slice((page - 1) * PER_PAGE, page * PER_PAGE); + const title = (tag ? esc(tag) + ' · ' : '') + 'Blog | LinkSpin'; + const desc = 'Coaching and teaching articles from Marty Bostick on building a line, advertising that pays, and doing the simple work every day.'; + const p = tag ? '/blog/tag/' + encodeURIComponent(tag) : '/blog' + (page > 1 ? '/page/' + page : ''); + let h = head({ title, desc, path: p }); + h += '

' + (tag ? 'Tag: ' + esc(tag) : 'The LinkSpin blog') + '

Notes on building a line, one honest day at a time.

' + esc(desc) + '

'; + h += slice.length ? slice.map(cardHtml).join('') : '

Nothing published yet. Check back soon.

'; + if (pages > 1) h += '
' + (page > 1 ? '← Newer' : '') + (page < pages ? 'Older →' : '') + '
'; + h += ''; + return h + tail(); +} +function renderPost(p, related) { + const url = SITE + '/blog/' + p.slug; + let h = head({ title: p.title + ' | LinkSpin', desc: p.excerpt, path: '/blog/' + p.slug, image: p.cover, article: p }); + h += '

Blog' + (p.tags[0] ? ' · ' + esc(p.tags[0]) + '' : '') + '

'; + h += '

' + esc(p.title) + '

By ' + esc(AUTHOR) + ' · ' + fmtDate(p.publishedAt || p.created) + ' · ' + readMinutes(p.body) + ' min read

'; + if (p.cover) h += '' + esc(p.title) + ''; + h += '
' + p.body + '
'; + if (p.tags.length) h += '

' + p.tags.map(t => '' + esc(t) + '').join('') + '

'; + h += ''; + h += authorBox(); + if (related.length) h += ''; + h += '

LinkSpin sells advertising. Nothing here is investment advice, no income is guaranteed, and cryptocurrency involves risk of loss.

'; + h += ''; + h += ''; + return h + tail(); +} +function relatedFor(p, posts) { + const scored = posts.filter(x => x.slug !== p.slug).map(x => ({ x, s: x.tags.filter(t => p.tags.includes(t)).length })); + return scored.sort((a, b) => b.s - a.s || (b.x.publishedAt || 0) - (a.x.publishedAt || 0)).slice(0, 3).map(r => r.x); +} +function rss(posts) { + const items = posts.slice(0, 30).map(p => '' + esc(p.title) + '' + SITE + '/blog/' + p.slug + '' + SITE + '/blog/' + p.slug + '' + new Date(p.publishedAt || p.created).toUTCString() + '' + esc(p.excerpt) + '').join(''); + return 'LinkSpin blog' + SITE + '/blogCoaching and teaching articles from Marty Bostick.' + items + ''; +} +function sitemap(posts) { + const pages = ['/', '/blog', '/whats-new', '/leaderboard', '/ledger', '/contract', '/plays', '/wallets', '/earning', '/partners']; + const u = pages.map(p => '' + SITE + p + 'weekly').join('') + + posts.map(p => '' + SITE + '/blog/' + p.slug + '' + new Date(p.updated).toISOString().slice(0, 10) + 'monthly').join(''); + return '' + u + ''; +} +const robots = () => 'User-agent: *\nAllow: /\nDisallow: /my\nDisallow: /admin\nDisallow: /api/\nDisallow: /view/\nSitemap: ' + SITE + '/sitemap.xml\n'; + +module.exports = { init, listAll, listPublished, get, save, remove, bumpViews, renderIndex, renderPost, relatedFor, rss, sitemap, robots, slugify, sanitize }; diff --git a/burner.js b/burner.js new file mode 100644 index 0000000..2ef3565 --- /dev/null +++ b/burner.js @@ -0,0 +1,138 @@ +// Automatic credit burner: settles pending campaign spend on-chain by calling +// consume() from the engine signer. Inert unless ENGINE_KEY (hex private key) +// or ENGINE_KEY_FILE is set. Purchased credits only ever go DOWN via this path, +// and members never sign or pay gas for it: the engine wallet pays. +let ethers = null; try { ethers = require('ethers'); } catch (e) { /* optional dependency */ } +const fs = require('fs'); +const path = require('path'); +// in-flight sends survive a restart: written to the volume before every send, cleared on receipt +const INFLIGHT_FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'burner-inflight.json'); +function loadInflight() { try { return JSON.parse(fs.readFileSync(INFLIGHT_FILE(), 'utf8')); } catch (e) { return {}; } } +function saveInflight(o) { try { fs.writeFileSync(INFLIGHT_FILE(), JSON.stringify(o)); } catch (e) {} } + +let chain = null, ads = null, accounts = null; +// another of the same account's positions that can cover this burn on-chain (credits are pooled per account) +async function fundedAlternative(b) { + if (!accounts) return null; + const owner = await ads.burnOwner(b.ref); if (!owner) return null; + const acct = await accounts.byEmail(owner); if (!acct) return null; + const ids = [acct.memberId, ...(await accounts.positions(owner)).map(p => p.memberId)].filter(id => id && id !== b.memberId); + for (const id of [...new Set(ids)]) { + try { const bal = await chain.creditBalance(id, 0); const held = await ads.unburnedFor(id); if (bal - held >= b.amount) return id; } catch (e) {} + } + return null; +} +const ABI = ['function consume(uint32 memberId_, uint8 creditType, uint256 amount, bytes32 campaignRef)', 'function engineSigner() view returns (address)']; +const state = { enabled: false, address: null, signer: null, balanceWei: '0', lastRun: 0, lastError: null, burned: 0, lastTx: null, mismatch: false, skipped: {} }; +let running = false; + +function keyHex() { + let k = String(process.env.ENGINE_KEY || '').trim(); + if (!k && process.env.ENGINE_KEY_FILE) { try { k = fs.readFileSync(process.env.ENGINE_KEY_FILE, 'utf8').trim(); } catch (e) {} } + if (!k) return null; + if (!k.startsWith('0x')) k = '0x' + k; + return /^0x[0-9a-fA-F]{64}$/.test(k) ? k : null; +} +let rpcIdx = 0; +function provider() { + const c = chain.getConfig(); + const urls = (c.rpcs && c.rpcs.length) ? c.rpcs : [c.rpc]; + return new ethers.JsonRpcProvider(urls[rpcIdx % urls.length], Number(c.chainId), { staticNetwork: true }); +} +function rotateRpc() { const c = chain.getConfig(); const n = (c.rpcs && c.rpcs.length) || 1; rpcIdx = (rpcIdx + 1) % n; } +async function setup() { + const k = keyHex(); + if (!k || !ethers) { state.enabled = false; return; } + const w = new ethers.Wallet(k, provider()); + state.address = w.address; state.enabled = true; + try { + const ctr = new ethers.Contract(chain.getConfig().contract, ABI, w); + const es = await ctr.engineSigner(); + state.mismatch = String(es).toLowerCase() !== w.address.toLowerCase(); + if (state.mismatch) console.error('burner: ENGINE_KEY address', w.address, 'is not the contract engineSigner', es, '- burns will revert; disabled'); + } catch (e) { state.lastError = 'engineSigner read: ' + e.message; } +} +function refToBytes32(ref) { return ethers.zeroPadBytes(ethers.toUtf8Bytes(String(ref || '').slice(0, 32)), 32); } +// the on-chain ref IS the burn id, so a burn that already mined can always be recognised +// from its CreditsConsumed event, even when the RPC lost the response (a lost response +// double-burned member #5's campaign 30 on 2026-09-10) +function alreadyMined(b) { + const want = refToBytes32(b.id).toLowerCase(); + const hit = chain.recentEvents(1e9).find(e => e.type === 'CreditsConsumed' && e.memberId === Number(b.memberId) && String(e.ref || '').toLowerCase() === want); + return hit ? hit.tx : null; +} +// ask the chain directly (not the index) whether this burn's ref already appears in a +// CreditsConsumed log for this member over roughly the last two hours +const TOPIC_CONSUMED = '0x97f58994fda6236f3659a1d723c3d42e81841551eae9243477a2948eac6aec46'; +async function minedOnChain(b) { + const want = refToBytes32(b.id).toLowerCase(); + const latest = parseInt(await chain.rpc('eth_blockNumber', []), 16); + const from = Math.max(0, latest - 3600); + const member = '0x' + Number(b.memberId).toString(16).padStart(64, '0'); + const logs = await chain.rpc('eth_getLogs', [{ address: chain.getConfig().contract, fromBlock: '0x' + from.toString(16), toBlock: 'latest', topics: [TOPIC_CONSUMED, member] }]); + for (const lg of logs || []) { + const d = String(lg.data || '').slice(2); + const ref = '0x' + d.slice(128, 192); + if (ref.toLowerCase() === want) return lg.transactionHash; + } + return null; +} + +async function tick() { + if (!state.enabled || state.mismatch || running || !ethers) return { burned: 0 }; + running = true; + let burned = 0; + try { + const w = new ethers.Wallet(keyHex(), provider()); + const ctr = new ethers.Contract(chain.getConfig().contract, ABI, w); + state.balanceWei = (await w.provider.getBalance(w.address)).toString(); + const pending = await ads.pendingBurns(); + state.lastRun = Date.now(); + if (BigInt(state.balanceWei) < ethers.parseEther('0.05')) { state.lastError = 'engine wallet low on POL for gas'; return { burned: 0 }; } + for (const b of pending.slice(0, 20)) { + try { + // Polygon nodes reject low priority fees and some public RPCs answer fee + // queries with 500s: set the fees ourselves from the server's estimate + const mined = alreadyMined(b); + if (mined) { await ads.markBurned(b.id, mined); burned += 1; state.burned += 1; continue; } + state.inflight = Object.assign(loadInflight(), state.inflight || {}); + if (state.inflight[b.id]) { + // a send whose answer we lost (or a restart mid-send): ask the chain itself before doing anything + let tx = null; try { tx = await minedOnChain(b); } catch (e) { state.lastError = 'chain check: ' + e.message; break; } + if (tx) { await ads.markBurned(b.id, tx); delete state.inflight[b.id]; saveInflight(state.inflight); burned += 1; state.burned += 1; continue; } + if (Date.now() - state.inflight[b.id] < 10 * 60000) continue; // give the network time; re-check next tick + } + // dry-run first: a revert here (usually "Insufficient credits", the member's on-chain + // balance is below what the engine metered) costs no gas and is left for the admin + try { await ctr.consume.staticCall(Number(b.memberId), 0, BigInt(b.amount), refToBytes32(b.id)); } + catch (e) { + const why = String(e.reason || e.shortMessage || e.message).slice(0, 120); + // the pinned position is dry: settle from another funded position on the same account + const alt = /insufficient credits/i.test(why) ? await fundedAlternative(b) : null; + if (!alt) { state.skipped[b.id] = why + (alt === null && /insufficient credits/i.test(why) ? ' (no funded position on the account)' : ''); continue; } + try { await ctr.consume.staticCall(Number(alt), 0, BigInt(b.amount), refToBytes32(b.id)); } + catch (e2) { state.skipped[b.id] = String(e2.reason || e2.shortMessage || e2.message).slice(0, 120); continue; } + await ads.reassignBurn(b.id, alt); b.memberId = alt; delete state.skipped[b.id]; + } + const g = await chain.suggestedFees(); + const overrides = { gasLimit: 120000n, maxPriorityFeePerGas: BigInt(g.maxPriorityFeePerGas), maxFeePerGas: BigInt(g.maxFeePerGas) }; + state.inflight[b.id] = Date.now(); saveInflight(state.inflight); + const tx = await ctr.consume(Number(b.memberId), 0, BigInt(b.amount), refToBytes32(b.id), overrides); + const rc = await tx.wait(1); + if (rc && rc.status === 1) { await ads.markBurned(b.id, tx.hash); delete state.inflight[b.id]; saveInflight(state.inflight); burned += 1; state.burned += 1; state.lastTx = tx.hash; state.lastError = null; } + else { state.lastError = 'consume reverted for burn ' + b.id; break; } + } catch (e) { + state.lastError = 'burn ' + b.id + ': ' + String(e.shortMessage || e.message).slice(0, 160); + // a node error (500, timeout, rate limit): move to the next RPC for the next tick. + // an "Insufficient credits" revert means the member's on-chain balance is + // already lower than the engine thinks; leave it pending for the admin to review + if (/server response|timeout|rate|429|503|502|500/i.test(String(e.message))) { rotateRpc(); break; } + state.skipped[b.id] = String(e.reason || e.shortMessage || e.message).slice(0, 120); + } + } + } finally { running = false; } + return { burned }; +} +function status() { return Object.assign({}, state, { hasEthers: !!ethers, keyPresent: !!keyHex() }); } +function init(opts) { chain = opts.chain; ads = opts.ads; accounts = opts.accounts || null; setup().catch(e => { state.lastError = e.message; }); } +module.exports = { init, tick, status }; diff --git a/carry.js b/carry.js new file mode 100644 index 0000000..d40021f --- /dev/null +++ b/carry.js @@ -0,0 +1,159 @@ +// Sponsor carry-over (LinkSpin, 2026-09-15). Marty's rule: a sponsor is never passed over for +// being absent, only for having no wallet. +// - At join: if the registry knows the email and no invite link was used, the member's +// InstantAdPay sponsor becomes their LinkSpin sponsor. If that sponsor has no LinkSpin +// account yet, a shell account is created for them (email, username, wallet from the +// registry) so the line exists on the site immediately; it becomes theirs the first time +// they sign in with that email. The sponsor is told. +// - Before a member's first activation or purchase: if the sponsor has a wallet but no +// position on this contract, the engine activates them on their behalf (activateFor, +// engine-signed, gas paid by the engine), walking up until it reaches someone already +// on-chain. The sponsor is told, and their first payout email follows on its own. +// - Sponsor with no wallet at all: the claim window. The member can do everything except +// buy; the sponsor has CLAIM_DAYS to link a wallet. After that the seat walks up to the +// nearest ancestor with a wallet and both sides are told. +let ethers = null; try { ethers = require('ethers'); } catch (e) {} +const fs = require('fs'); +const path = require('path'); + +let X = {}; // { dataDir, accounts, registry, chain, mailer, messages, siteConfig, adminEmail } +const DAY = 86400000; +const ABI = ['function activateFor(address account, uint32 sponsorId) returns (uint32)', 'function engineSigner() view returns (address)']; +const norm = e => String(e || '').trim().toLowerCase(); + +function init(opts) { X = opts; } +function claimDays() { const n = Number((X.siteConfig && X.siteConfig().carryClaimDays) || 3); return n > 0 ? n : 3; } +const STATE = () => path.join(X.dataDir, 'carry-state.json'); +function state() { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')); } catch (e) { return { claims: {}, log: [] }; } } +function setState(s) { try { fs.writeFileSync(STATE(), JSON.stringify(s)); } catch (e) {} } +function logEvent(ev) { const s = state(); s.log = (s.log || []).concat([Object.assign({ ts: Date.now() }, ev)]).slice(-500); setState(s); } + +function keyHex() { + let k = String(process.env.ENGINE_KEY || '').trim(); + if (!k && process.env.ENGINE_KEY_FILE) { try { k = fs.readFileSync(process.env.ENGINE_KEY_FILE, 'utf8').trim(); } catch (e) {} } + if (!k) return null; if (!k.startsWith('0x')) k = '0x' + k; + return /^0x[0-9a-fA-F]{64}$/.test(k) ? k : null; +} +function engineReady() { return !!(ethers && keyHex()); } +function signer() { + const c = X.chain.getConfig(); const urls = (c.rpcs && c.rpcs.length) ? c.rpcs : [c.rpc]; + const p = new ethers.JsonRpcProvider(urls[0], Number(c.chainId), { staticNetwork: true }); + return new ethers.Wallet(keyHex(), p); +} + +// ---- notices: email + on-site inbox, sent as the company account ---- +async function tell(email, subject, text) { + try { if (X.mailer && X.mailer.hasKey()) X.mailer.send(email, subject, text + '\n\nLinkSpin').catch(() => {}); } catch (e) {} + try { + const html = '

' + String(text).replace(/&/g, '&').replace(/$1').split('\n\n').join('

').replace(/\n/g, '
') + '

'; + if (X.messages) await X.messages.deliver(1, X.adminEmail || 'house@linkspin.co', [email], subject, html); + } catch (e) {} +} +const label = a => a ? (a.username ? '@' + a.username : a.email.replace(/^(.).*(@.*)$/, '$1***$2')) : 'a member'; + +// ---- join: give the member their network sponsor if no link was used ---- +async function onJoin(acct, usedRef) { + try { + const reg = await X.registry.get(acct.email); + // carry the member's own facts (username, wallet) forward when the site does not have them yet + if (reg && reg.username && !acct.username) { try { await X.accounts.setUsername(acct.email, reg.username); } catch (e) {} } + if (usedRef || !reg || !reg.sponsorEmail) return { carried: false }; + const spReg = await X.registry.get(reg.sponsorEmail); if (!spReg) return { carried: false }; + let sp = await X.accounts.byEmail(spReg.email); + if (!sp) { + // shell account for the sponsor: exists on the site now, becomes theirs at first sign-in + const r = await X.accounts.ensure(spReg.email, '', 'carried', 'network'); + sp = r.account; + if (spReg.username && !sp.username) { try { await X.accounts.setUsername(spReg.email, spReg.username); sp = await X.accounts.byEmail(spReg.email); } catch (e) {} } + if (spReg.wallet && !sp.address) { try { await X.accounts.linkWallet(spReg.email, spReg.wallet); sp = await X.accounts.byEmail(spReg.email); } catch (e) {} } + // give the shell its own network sponsor too, so the line keeps climbing + if (spReg.sponsorEmail) { const up = await X.accounts.byEmail(spReg.sponsorEmail); const upReg = await X.registry.get(spReg.sponsorEmail); const tok = up ? (up.username || up.code) : (upReg && upReg.username); if (tok) await X.accounts.setSponsorRef(spReg.email, tok); } + } + const tok = sp.username || sp.code; + await X.accounts.setSponsorRef(acct.email, tok); + logEvent({ type: 'carried', member: acct.email, sponsor: sp.email }); + await tell(sp.email, label(acct) + ' just joined LinkSpin under you', + 'Your InstantAdPay referral ' + label(acct) + ' just joined LinkSpin, and the network placed them under you, the same sponsor line you already built.\n\n' + + (sp.address ? 'Your LinkSpin position will be set up for you the moment it is needed, so you are paid from their first purchase whether or not you have signed in yet. ' : 'Link a wallet on LinkSpin within ' + claimDays() + ' days to be their sponsor on this property; without a wallet there is nothing for the contract to pay. ') + + 'Sign in with this email to see your line: https://' + (X.host || 'linkspin.co') + '/my'); + return { carried: true, sponsor: sp.email }; + } catch (e) { return { carried: false, error: e.message }; } +} + +// ---- before activation / purchase: make sure the sponsor exists on this contract ---- +// returns { sponsorId, hold, claim } — hold is set when the buy must wait (claim window) +async function resolveForChain(acct, spd) { + if (!acct || !acct.sponsorRef) return null; + if (spd.id) return null; // already on-chain: nothing to do + if (spd.reason !== 'notActivated') return null; // unknown token or rpc: the normal guard handles it + let sp = await X.accounts.byCode(acct.sponsorRef); if (!sp) sp = await X.accounts.byUsername(acct.sponsorRef); + if (!sp) return null; + if (sp.address) { + const id = await ensureOnChain(sp, 0); + return id ? { sponsorId: id } : null; + } + // no wallet: the claim window + const st = state(); const k = norm(acct.email); + let c = st.claims[k]; + if (!c) { + c = { member: k, sponsor: sp.email, opened: Date.now(), deadline: Date.now() + claimDays() * DAY, notified: true }; st.claims[k] = c; setState(st); + await tell(sp.email, label(acct) + ' is ready to buy on LinkSpin. Link a wallet to be their sponsor', + label(acct) + ' joined LinkSpin under you and wants to buy a package. The contract can only pay a wallet, and there is none on your LinkSpin account yet.\n\nLink one within ' + claimDays() + ' days (Wallet tab, one free signature) and their purchases pay you. If the window passes, the seat goes to the next person above you who has a wallet.\n\nhttps://' + (X.host || 'linkspin.co') + '/my#wallet'); + } + if (Date.now() < c.deadline) return { sponsorId: 0, hold: true, claim: { sponsor: label(sp), deadline: c.deadline } }; + // window passed: walk up to the nearest ancestor with a wallet, activate them, tell both sides + const up = await nearestWithWallet(sp.email); + if (!up) return { sponsorId: 0, hold: true, claim: { sponsor: label(sp), deadline: c.deadline, nobody: true } }; + const id = await ensureOnChain(up, 0); + if (!id) return null; + await X.accounts.setSponsorRef(acct.email, up.username || up.code); + delete st.claims[k]; setState(st); logEvent({ type: 'walkedUp', member: acct.email, from: sp.email, to: up.email }); + await tell(sp.email, 'The seat for ' + label(acct) + ' passed to ' + label(up), 'No wallet was linked within ' + claimDays() + ' days, so ' + label(acct) + ' was placed under ' + label(up) + ', the nearest person above you with a wallet. Link a wallet now and everyone who joins under you from here on is yours.'); + await tell(up.email, label(acct) + ' was placed under you on LinkSpin', label(acct) + ' joined through ' + label(sp) + ', who has no wallet on LinkSpin, so the network placed them under you. Their purchases pay you from now on.'); + return { sponsorId: id, movedTo: up.email }; +} + +async function nearestWithWallet(email) { + let cur = await X.accounts.byEmail(email); const seen = new Set([norm(email)]); let hops = 0; + while (cur && cur.sponsorRef && hops < 25) { + let sp = await X.accounts.byCode(cur.sponsorRef); if (!sp) sp = await X.accounts.byUsername(cur.sponsorRef); + if (!sp || seen.has(norm(sp.email))) return null; seen.add(norm(sp.email)); hops++; + if (sp.address) return sp; cur = sp; + } + return null; +} + +// activate `acct` on this contract via the engine, after its own sponsor is there (recursive, bounded) +async function ensureOnChain(acct, depth) { + if (!acct || !acct.address) return 0; + try { const id = await X.chain.memberIdByAccount(acct.address); if (id) return id; } catch (e) {} + if (!engineReady() || depth > 25) return 0; + let sponsorId = 0; + if (acct.sponsorRef) { + let sp = await X.accounts.byCode(acct.sponsorRef); if (!sp) sp = await X.accounts.byUsername(acct.sponsorRef); + if (sp && sp.address) sponsorId = await ensureOnChain(sp, depth + 1); + } + if (!sponsorId) sponsorId = Number((X.siteConfig && X.siteConfig().defaultSponsorId) || 1) || 1; + try { + const w = signer(); const ctr = new ethers.Contract(X.chain.getConfig().contract, ABI, w); + const tx = await ctr.activateFor(acct.address, sponsorId, { gasLimit: 160000n }); + await tx.wait(1); + const id = await X.chain.memberIdByAccount(acct.address); + logEvent({ type: 'activated', email: acct.email, id, sponsorId, tx: tx.hash }); + await tell(acct.email, 'We set up your LinkSpin position so you would not miss a payout', + 'Someone in your line was about to buy on LinkSpin, so the network activated your position on the LinkSpin contract for your wallet ' + acct.address.slice(0, 6) + '…' + acct.address.slice(-4) + ', bound to your own sponsor. Nothing was charged and nothing else was touched. Your share of that purchase lands in this wallet in the same transaction.\n\nSee your line: https://' + (X.host || 'linkspin.co') + '/my'); + return id; + } catch (e) { logEvent({ type: 'activateFailed', email: acct.email, error: String(e.shortMessage || e.message).slice(0, 160) }); return 0; } +} + +// pre-seed: activate every account with a wallet, sponsors before their downline +async function seedAll(limit) { + const all = await X.accounts.listAll(5000); + const withWallet = all.filter(a => a.address); + let done = 0, failed = 0; + for (const a of withWallet) { if (limit && done + failed >= limit) break; const id = await ensureOnChain(a, 0); if (id) done++; else failed++; } + return { candidates: withWallet.length, done, failed }; +} +function status() { const s = state(); return { engine: engineReady(), claims: Object.values(s.claims || {}), log: (s.log || []).slice(-50).reverse(), claimDays: claimDays() }; } + +module.exports = { init, onJoin, resolveForChain, ensureOnChain, seedAll, status, engineReady }; diff --git a/chain.js b/chain.js new file mode 100644 index 0000000..e2df305 --- /dev/null +++ b/chain.js @@ -0,0 +1,276 @@ +// On-chain reader + indexer for the LinkSpin contract. +// Free public RPCs only, zero npm dependencies (RM Circle pattern). +// +// Two jobs: +// 1. READS: member/product/quote lookups via eth_call. +// 2. LIVE TAIL: eth_getLogs over the recent window feeds the public +// transparency ledger. State persists in DATA_DIR across redeploys. +// +// The contract address + chain live in data/config.json so the SAME code +// runs the Amoy dress rehearsal and, later, mainnet (flip config, wipe DB). +const fs = require('fs'); +const path = require('path'); +const https = require('https'); + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); +const CONFIG_FILE = path.join(DATA_DIR, 'config.json'); +const STATE_FILE = path.join(DATA_DIR, 'chain-index.json'); + +// keccak-256 topic hashes, precomputed with cast 2026-09-04 +const TOPICS = { + '0x271ca08b7d4244a2c931d4d329c113aab66e22de98163d90ed791a667735864e': 'MemberActivated', + '0x11dc109cafd0f24c81621f745383ec82be7f2947c650355791e27b56ac83bc8c': 'Purchase', + '0x5bbc207bba1439ff320c25b90f91258bfaeafb156b09481f148da52be4d7ce8e': 'TierPaid', + '0x628405f02369b6b9fc70c1c84e675613626898bcfa536383fdb33695cfd68f7d': 'PassedUp', + '0x3999769fc9743f7d4fe9e264d6e9575e8613d17ca29ea7d887b341e17238e0d1': 'AdminPaid', + '0x980b1d1cb448ce10b9e9f6f41af3fe5610e4084378c81fd0e0a7f5bbf9360bf8': 'BuyerCounted', + '0x97f58994fda6236f3659a1d723c3d42e81841551eae9243477a2948eac6aec46': 'CreditsConsumed', + '0x756a68c7a9e11c294245f97f39923944a5d81db3794102f15ca600934e88ae23': 'AwardPaid', + '0x1f044acf816321a559e13e967225bcf188d869107e381d2b793a469f122c0f69': 'ProductAdded', + '0xafb990f51e0a69f1a1c2ae42a5dc54543e9fafc12ba1c95d130737502070b783': 'PriceChangeQueued', + '0xfa5bbf62287a1aea9b1e3ed371e906f83229f094c5fe8a39c73036f557189580': 'PriceChanged', + '0xc361eff50f1c2ee869e4ddb66dede88a03a9471fb738c680d55fe6ac37f8d459': 'ProductRetired', + '0x3b00801a940479d5435f6ef82acfc5071c5e9bdcddac5150ed53f299366ddd51': 'ProductReactivated', + '0xc46f23bfe0653cac1e97856ba6f31cc9efb436822e835bd6331789bfd574d0e5': 'PriceCached', + '0xf1dfddc73b1fe570da41b81e839cbefa121a067726d2f770d17faa01d0146fb7': 'FallbackPriceUsed' +}; +const SEL = { + quoteWei: '0x7de85694', // quoteWei(uint32) + memberId: '0x39106821', // memberId(address) + members: '0x5f59bb40', // members(uint32) + creditBalance: '0x3a1d5b5c', // creditBalance(uint32,uint8) + products: '0xbf712fd6', // products(uint32) + productCount: '0xe0f6ef87', // productCount() + memberCount: '0x11aee380' // memberCount() +}; + +const CHUNK = 9000; +const POLL_MS = 30000; +const KEEP_EVENTS = 600; + +let cfg = null; +let state = null; +let busy = false; +let onEvent = null; + +function getConfig() { + if (!cfg) { + let saved = {}; + try { saved = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch (e) {} + cfg = Object.assign({ + // Amoy rehearsal defaults; mainnet flips these in the volume config + contract: '0x07786E664AAfc0641eEB5297766935763dbA2288', + chainId: 80002, + chainName: 'Polygon Amoy (testnet rehearsal)', + explorer: 'https://amoy.polygonscan.com', + rpcs: ['https://polygon-amoy-bor-rpc.publicnode.com', 'https://polygon-amoy.drpc.org'], + deployBlock: 46717266 // feed deploy block on Amoy (exact, from broadcast receipts) + }, saved); + } + return cfg; +} +function reloadConfig() { cfg = null; return getConfig(); } + +// ---- JSON-RPC with fallback rotation ---- +let rpcIdx = 0; +function rpcOnce(url, method, params) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }); + const u = new URL(url); + const req = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 15000 }, + res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { + try { const j = JSON.parse(d); if (j.error) reject(new Error(j.error.message)); else resolve(j.result); } + catch (e) { reject(e); } }); }); + req.on('error', reject); req.on('timeout', () => { req.destroy(new Error('rpc timeout')); }); + req.end(body); + }); +} +async function rpc(method, params) { + const urls = getConfig().rpcs; + let last; + // 3 rounds over the RPC pool with backoff — the public Amoy nodes routinely + // return transient "Temporary internal error. Please retry" on eth_call, and a + // single miss was silently surfacing as 0 credits / chainReadError on the + // dashboard. Retry so a real read isn't lost to a momentary node hiccup. + for (let round = 0; round < 3; round++) { + for (let i = 0; i < urls.length; i++) { + const url = urls[(rpcIdx + i) % urls.length]; + try { const r = await rpcOnce(url, method, params); rpcIdx = (rpcIdx + i) % urls.length; return r; } + catch (e) { last = e; } + } + if (round < 2) await new Promise(r => setTimeout(r, 400 * (round + 1))); + } + throw last || new Error('all RPCs failed'); +} + +// ---- ABI helpers ---- +const strip = h => (h || '').replace(/^0x/, ''); +const word = (data, i) => strip(data).slice(i * 64, i * 64 + 64); +const toBig = h => BigInt('0x' + (strip(h) || '0')); +const toNum = h => Number(toBig(h)); +const toAddr = h => '0x' + strip(h).slice(-40); +const pad = (v, bits) => BigInt(v).toString(16).padStart(64, '0'); +function decodeString(data, wordIdx) { + try { + const off = toNum(word(data, wordIdx)) / 32; + const len = toNum(word(data, off)); + return Buffer.from(strip(data).slice((off + 1) * 64, (off + 1) * 64 + len * 2), 'hex').toString('utf8'); + } catch (e) { return ''; } +} +async function call(sel, args) { + const data = sel + (args || []).map(a => pad(a)).join(''); + return rpc('eth_call', [{ to: getConfig().contract, data }, 'latest']); +} + +// ---- reads ---- +async function memberIdByAccount(addr) { + const r = await call(SEL.memberId, [BigInt(addr)]); + return toNum(word(r, 0)); +} +async function memberCount() { return toNum(word(await call(SEL.memberCount), 0)); } +async function productCount() { return toNum(word(await call(SEL.productCount), 0)); } +async function member(id) { + const r = await call(SEL.members, [id]); + return { account: toAddr(word(r, 0)), sponsorId: toNum(word(r, 1)), buyerCount: toNum(word(r, 2)), + activated: toNum(word(r, 3)) === 1, countedAsBuyer: toNum(word(r, 4)) === 1 }; +} +async function product(id) { + const r = await call(SEL.products, [id]); + return { id, priceCents: toNum(word(r, 0)), creditType: toNum(word(r, 1)), + creditAmount: Number(toBig(word(r, 2))), active: toNum(word(r, 3)) === 1, exists: toNum(word(r, 4)) === 1 }; +} +async function quoteWei(id) { try { return toBig(word(await call(SEL.quoteWei, [id]), 0)); } catch (e) { return null; } } +async function creditBalance(id, type) { return Number(toBig(word(await call(SEL.creditBalance, [id, type || 0]), 0))); } +async function catalog() { + const n = await productCount(); + const out = []; + for (let i = 1; i <= n; i++) { + const p = await product(i); + if (!p.exists || !p.active) continue; + const q = await quoteWei(i); + out.push(Object.assign(p, { costWei: q === null ? null : q.toString() })); + } + return out; +} + +// ---- event decode ---- +function decodeLog(log) { + const name = TOPICS[log.topics[0]]; + if (!name) return null; + const t = i => log.topics[i]; + const d = log.data; + const base = { type: name, block: parseInt(log.blockNumber, 16), tx: log.transactionHash, li: parseInt(log.logIndex, 16) }; + switch (name) { + case 'MemberActivated': return Object.assign(base, { id: toNum(t(1)), account: toAddr(t(2)), sponsorId: toNum(word(d, 0)) }); + case 'Purchase': return Object.assign(base, { buyerId: toNum(t(1)), productId: toNum(t(2)), + paidWei: toBig(word(d, 0)).toString(), priceCents: toNum(word(d, 1)), creditType: toNum(word(d, 2)), creditAmount: Number(toBig(word(d, 3))) }); + case 'TierPaid': return Object.assign(base, { buyerId: toNum(t(1)), recipientId: toNum(t(2)), + tier: toNum(word(d, 0)), amountWei: toBig(word(d, 1)).toString(), hops: toNum(word(d, 2)) }); + case 'PassedUp': return Object.assign(base, { buyerId: toNum(t(1)), tier: toNum(word(d, 0)), skippedId: toNum(word(d, 1)), reason: decodeString(d, 2) }); + case 'AdminPaid': return Object.assign(base, { buyerId: toNum(t(1)), amountWei: toBig(word(d, 0)).toString() }); + case 'BuyerCounted': return Object.assign(base, { sponsorId: toNum(t(1)), newBuyerId: toNum(t(2)), newCount: toNum(word(d, 0)) }); + case 'CreditsConsumed': return Object.assign(base, { memberId: toNum(t(1)), creditType: toNum(word(d, 0)), amount: Number(toBig(word(d, 1))), ref: word(d, 2) }); + case 'AwardPaid': return Object.assign(base, { from: toAddr(t(1)), toId: toNum(t(2)), amountWei: toBig(word(d, 0)).toString() }); + default: return base; // catalog/oracle events: type + tx is enough for the feed + } +} + +// ---- persistent live tail ---- +function loadState() { + try { state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch (e) { state = null; } + if (!state || state.v !== 1) state = { v: 1, lastBlock: getConfig().deployBlock - 1, events: [] }; + if (!state.totals) { for (const ev of state.events) tally(ev); saveState(); } // one-time backfill +} +function saveState() { + try { + const tmp = STATE_FILE + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(state)); + fs.renameSync(tmp, STATE_FILE); + } catch (e) { console.error('chain state save failed', e.message); } +} +// Most-synced tip across the RPC pool. Public load-balanced nodes (publicnode) +// sometimes answer eth_blockNumber from a replica lagging thousands of blocks +// behind — which stalls the scan (latest < lastBlock) and, worse, could let a +// getLogs from a lagging node skip freshly-mined events for good. Take the MAX +// height and remember which node reported it, so we scan against a node we know +// is synced to that height. +async function bestTip() { + const urls = getConfig().rpcs; + let best = { block: 0, url: urls[0] }; + await Promise.all(urls.map(async u => { + try { const b = parseInt(await rpcOnce(u, 'eth_blockNumber', []), 16); + if (b > best.block) best = { block: b, url: u }; } catch (e) {} + })); + return best; +} +async function tail() { + if (busy) return; busy = true; + try { + const tip = await bestTip(); + const latest = tip.block; + while (state.lastBlock < latest) { + const from = state.lastBlock + 1; + const to = Math.min(from + CHUNK - 1, latest); + // scan against the node we confirmed is synced to `latest`; fall back to + // the rotating pool only if that specific node errors on this range. + let logs; + try { logs = await rpcOnce(tip.url, 'eth_getLogs', [{ address: getConfig().contract, + fromBlock: '0x' + from.toString(16), toBlock: '0x' + to.toString(16) }]); } + catch (e) { logs = await rpc('eth_getLogs', [{ address: getConfig().contract, + fromBlock: '0x' + from.toString(16), toBlock: '0x' + to.toString(16) }]); } + for (const lg of logs) { + const ev = decodeLog(lg); + if (!ev) continue; + ev.ts = Date.now(); // indexed-at time: powers honest time-series charts + state.events.push(ev); + tally(ev); + if (onEvent) { try { onEvent(ev); } catch (e) { console.error('chain onEvent', e.message); } } + } + if (state.events.length > KEEP_EVENTS) state.events = state.events.slice(-KEEP_EVENTS); + state.lastBlock = to; + } + saveState(); + } catch (e) { console.error('chain tail', e.message); } + busy = false; +} +function recentEvents(n) { return state ? state.events.slice(-(n || 100)).reverse() : []; } + +// running totals for the public counters — every number provable on-chain +function tally(ev) { + if (!state.totals) state.totals = { purchases: 0, paidInWei: '0', payouts: 0, payoutWei: '0', activations: 0 }; + const t = state.totals; + if (ev.type === 'Purchase') { t.purchases += 1; t.paidInWei = (BigInt(t.paidInWei) + BigInt(ev.paidWei)).toString(); } + if (ev.type === 'TierPaid') { t.payouts += 1; t.payoutWei = (BigInt(t.payoutWei) + BigInt(ev.amountWei)).toString(); } + if (ev.type === 'AwardPaid') { t.payouts += 1; t.payoutWei = (BigInt(t.payoutWei) + BigInt(ev.amountWei)).toString(); } + if (ev.type === 'MemberActivated') t.activations += 1; +} +function totals() { + return state && state.totals ? state.totals : { purchases: 0, paidInWei: '0', payouts: 0, payoutWei: '0', activations: 0 }; +} + +function init(opts) { + if (opts && opts.onEvent) onEvent = opts.onEvent; + getConfig(); loadState(); + tail(); + setInterval(tail, POLL_MS); +} + +// Recommended EIP-1559 fees straight from the network. Polygon Amoy's Bor nodes +// enforce a ~25 gwei minimum priority fee, but wallets (notably MetaMask) apply +// a stale low estimate and get the raw tx rejected ("gas tip below minimum"). +// We hand the wallet correct fees so the tx clears the floor: a priority tip at +// or above the network suggestion (floored at 30 gwei for headroom) and a +// maxFee that covers 2x base + tip so MetaMask never flags "max fee too low". +async function suggestedFees() { + let tip = 0n, base = 0n; + try { tip = BigInt(await rpc('eth_maxPriorityFeePerGas', [])); } catch (e) {} + try { const blk = await rpc('eth_getBlockByNumber', ['latest', false]); base = BigInt((blk && blk.baseFeePerGas) || '0x0'); } catch (e) {} + const MIN_TIP = 30000000000n; // 30 gwei — safely over Amoy's ~25 gwei floor + const priority = tip > MIN_TIP ? tip : MIN_TIP; + const maxFee = base * 2n + priority; + return { maxPriorityFeePerGas: '0x' + priority.toString(16), maxFeePerGas: '0x' + maxFee.toString(16) }; +} + +module.exports = { init, getConfig, reloadConfig, memberIdByAccount, memberCount, member, + product, productCount, quoteWei, creditBalance, catalog, recentEvents, totals, rpc, decodeLog, suggestedFees }; diff --git a/chatbot.js b/chatbot.js new file mode 100644 index 0000000..e94fc6d --- /dev/null +++ b/chatbot.js @@ -0,0 +1,168 @@ +// 24/7 site assistant (RM Circle pattern): canned answers first for the +// questions everyone asks, OpenRouter AI for everything else, hard honesty +// rules baked into the system prompt. +// +// HOUSE RULE: whenever a site feature changes, update BOTH the CANNED +// answers and FACTS below in the same commit. +const fs = require('fs'); +const path = require('path'); +const https = require('https'); + +let DATA_DIR = null; +let chain = null; +const MODEL = process.env.OPENROUTER_MODEL || 'deepseek/deepseek-v4-flash:nitro'; + +function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; } +function key() { + if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY.trim(); + try { return fs.readFileSync(path.join(DATA_DIR, 'openrouter.key'), 'utf8').trim(); } catch (e) { return ''; } +} +function enabled() { return !!key(); } + +// ---- canned answers: instant, free, always in voice ---- +const CANNED = [ + { re: /(pyramid|ponzi|scam|scheme)/i, + a: 'Fair question. A pyramid pays for recruiting with no product; here every payment is for real ad delivery, bought by real advertisers, and the whole money flow is public. Check the code and every payout yourself at https://linkspin-test.saasy.top/contract and https://linkspin-test.saasy.top/ledger.' }, + { re: /(withdraw|cash ?out|payout request|minimum payout)/i, + a: 'There are no withdrawals here, ever. The contract pays your share straight to your own wallet in the same transaction as the purchase. Nothing to request, nothing to wait on.' }, + { re: /(how (much|do i) earn|commission|percent|split)/i, + a: 'Every package splits the same way: 50 percent to the direct sponsor, 20 percent to level 2, 10 percent to level 3, 20 percent to the platform. Those numbers are constants in the contract and cannot be changed. Play with scenarios on the home page calculator. No income is promised; nobody earns unless real ad buying happens.' }, + { re: /(balance (went|going|keeps going) down|credits? (disappear|missing|gone|dropping|went down|ticking)|why (is|did) my (balance|credits)|in campaigns|set aside|reserved credits|available to spend)/i, + a: 'One rule: your balance is what is NOT committed to a live campaign. When you start a campaign its whole budget is set aside right then (earned credits first, then purchased), so your Available number drops once and then sits still while the ad runs. The budget spends down inside that campaign in Members > Campaigns, where you can see delivered views, network views and what is left. The dashboard shows Purchased available, Earned available and In live campaigns as three separate numbers; a paused campaign keeps its unspent budget set aside so it can resume. Nothing is lost when a balance reads low while a campaign is live. Purchased credits live on the blockchain; if we ever refund or credit purchased money it shows under Purchased as "credited to you" and spends like a purchase, login ads included. Earned credits fund every format except login ads.' }, + { re: /(qualif|unlock level|level 2|level 3|pass.?up)/i, + a: 'Two different things are earned here. CREDITS: every member, free ones included, earns ad credits by viewing ads, videos, visits and the daily sign-in; credits buy your own ads and are never withdrawn. POL: real crypto paid to your wallet when your referrals buy packages, once you are activated: the $20 starter package plus payouts switched on from your wallet. Level 1 pays 50 percent of the packages your direct referrals buy, from the day you are activated. Bring 2 buyers of $20 or more and level 2 unlocks; 5 unlock level 3. When a level is not qualified, its share climbs the sponsor line, checking up to 25 positions, and pays the first qualified person. Qualification never expires and cannot be bought. Qualified Start: you may link extra wallets of your own as positions under your account (Buy packages > Qualified Start); each one that buys a $20+ package counts as a qualifying buyer, its credits pool with yours, and 50% of its purchase comes back to your main wallet. Your own money, your own wallets, a faster start, never an income promise.' }, + { re: /(free member|without (a )?wallet|do i (have to|need to) buy|earn (in )?credits|credits (vs|versus|or) pol|earn pol|get paid in)/i, + a: 'Free members earn ad credits: view ads, watch videos, make verified visits, read inbox ads, sign in daily. Credits buy your own ads and are never cashed out. POL, the real crypto, is paid to your wallet by the contract when your referrals buy packages, once you are activated: the $20 starter package (2,000 credits to advertise with) and payouts switched on from the Wallet tab. Your own $20 purchase also makes you count as a qualifying buyer for your sponsor, and two or five such buyers in your own line open levels 2 and 3 for you.' }, + { re: /(need (a )?wallet|crypto experience|metamask|how (do i|to) join|sign ?up|register)/i, + a: 'Join free with just your email at https://linkspin-test.saasy.top/my, no wallet and no password needed. Your wallet only comes out when you buy a package or switch on payouts, and the site walks you through it.' }, + { re: /(referral link|invite link|share link|refer)/i, + a: 'You get your share link the moment you sign in, free members included. Open Members > Promo tools: the "Your links" block at the top lists your invite link plus five angle versions (Instant, Ad spend, Free, Ledger, Two), each with a note on who it is for and Copy and Share buttons. They are the same link with a code on the end, so every join and purchase credits you; only the first page the person sees changes. 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: /(promo tools?|social posts?|email swipes?|banners?|text a friend|objection|share buttons?)/i, + a: 'Promo tools (Members > Promo tools) has pill menus for each kit: Social posts (X, Facebook, LinkedIn, Telegram or WhatsApp, with post and share buttons), Text a friend (SMS-sized messages with Text it, WhatsApp, Telegram and Copy), Email swipes (short, standard, long, follow-up), Banners (every standard ad size plus square, story and Telegram sizes, download or copy URL), your Banner wall link, and an Objection handling bank with the truth plus a ready-to-send reply. Everything is personalized with your invite link.' }, + { re: /(drain (my|your) wallet|stop and go back|trust wallet.*(warn|block|red)|wallet (warning|blocked))/i, + a: 'That red "this transaction will drain your wallet" screen is the wallet\'s own safety rule, not a problem with the purchase: Trust Wallet blocks any transaction that spends most of the POL in the wallet. Ways through: buy a smaller package first, add some POL so the purchase is well under half the balance, or connect a different wallet (MetaMask, Phantom, SafePal have no such block). Extra POL always stays yours. If the next try says WalletConnect disconnected, open Wallet, tap Disconnect, then Connect again. Phantom, SafePal and MetaMask do not have the hard block.' }, + { re: /(wall (page|slots?|positions?)|banner wall|my wall|three (ads|slots) on (my|the) wall)/i, + a: 'Your wall (linkspin-test.saasy.top/wall/yourname) shows three ads. Position 1 is always your line banner. Positions 2 and 3 show your upline (or LinkSpin) until you earn them: 2 qualifying buyers ($20 or more) make position 2 yours, 5 make position 3 yours, so a fully qualified member owns the whole page with their own links and offers. Set them in Profile > Your wall; an unlocked slot you leave empty keeps showing your upline until you fill it.' }, + { 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: /(wrong sponsor|who is my sponsor|which sponsor|sponsor (cookie|link|credit)|clicked (someone|another|two).*link|last (link|click)|first (link|click|touch)|change (my )?sponsor)/i, + a: 'The invite link you opened most recently sets your sponsor, and the join page shows it: "Personal invitation from @name". It locks the moment you create your account, so opening someone else\'s link afterwards changes nothing, and your first purchase binds you to that same sponsor on the contract. If you have not joined yet and the page shows the wrong name, open the right person\'s link and it updates. If you already joined under the wrong sponsor by mistake, message support before your first purchase and the admin can move you.' }, + { 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, + a: 'Packages mint ad credits on-chain, and every new member also gets a small batch of welcome credits just for joining. One credit is one cent of ad delivery: banners, text ads, full-screen login ads, and solo ads across the network, managed from the campaign manager in Members. Only your campaigns can spend your credits.' }, + { re: /(price|cost|package|how much is)/i, + a: 'Packages run 5 to 250 dollars, priced in dollars and settled in POL at the live rate when you buy. The full ladder with live pricing is on the home page. Packages of $20 or more count toward qualification.' }, + { re: /(which|what) (crypto|coin|token|currency|chain|network)|paid in what|get paid in|supported (crypto|coins|wallets)|do you (support|accept)/i, + a: 'One coin, one network: POL, the native coin of Polygon. You pay for packages in POL and every payout arrives as POL in your own Polygon wallet, in the same transaction. No other coins, tokens or chains are used. Any Polygon wallet works (MetaMask, SafePal, Phantom, Coinbase Wallet), and you can buy POL with a card from Buy packages if you have never held crypto.' }, + { re: /(which wallet|what wallet|best wallet|recommended wallet|set ?up (a )?wallet|buy (pol|crypto|polygon)|get (pol|crypto)|moonpay|card to crypto|no crypto|never (had|held|owned) crypto)/i, + a: 'Preferred wallets: MetaMask (recommended, and the one for Qualified Start extra accounts), Phantom (turn Polygon on first: Settings, Active Networks), SafePal or Coinbase Wallet. Trust Wallet works but blocks a purchase that spends most of the POL in it, so keep about double the package cost there. To buy POL with a card: connect your wallet, open Buy packages, tap "Buy POL with a card". MoonPay opens with POL on Polygon and your own address filled in; pay by card, Apple Pay or Google Pay (first time needs an ID check, minimum order around $30), the POL lands in your wallet in minutes, then buy the package. The full step-by-step guide is in Training: Wallets and buying POL (https://linkspin-test.saasy.top/wallets, members only).' }, + { re: /(safe|trust|rug|run away|company disappear)/i, + a: 'The money never touches us. An immutable contract splits every purchase in the same transaction, holds zero balance, and has no pause switch or upgrade path. Read the plain-language review and the verified source at https://linkspin-test.saasy.top/contract.' } +]; + +// ---- facts for the AI fallback ---- +function systemPrompt() { + return `You are the assistant on LinkSpin (https://linkspin-test.saasy.top), a membership advertising platform. + +FACTS: +- Free to join with email only (6-digit code sign-in, no passwords). Wallet appears only at purchase or payout activation. Every new member gets a small welcome batch of ad credits — unlocked by the WELCOME TOUR on first sign-in: they visit their upline's line-banner sites (up to 3, 10 seconds each — the same 3 levels the contract pays), then claim the credits. Members with no upline banners get the credits instantly. +- SPONSOR ATTRIBUTION: last touch. The invite link opened most recently sets the sponsor (30-day cookie), shown on the join page and again at the code step ("Joining under @name"). It locks when the account is created; the contract binds the buyer to that sponsor at their first purchase. Wrong sponsor before joining: open the right link. Already joined wrong: admin can move the account before the first purchase. +- YOUR LINKS (top of Members > Promo tools): the plain invite link plus five angle links (?v=instant paid-in-seconds, ?v=adspend you-buy-ads-anyway, ?v=free costs-nothing-to-try, ?v=ledger no-back-office, ?v=two two-buyers-open-level-two), each with a who-it-is-for note, Copy and Share. Same credit either way; only the first page differs. Link stats (Coaching pane) shows views/joins/buyers per angle. +- PROMO TOOLS (Members > Promo tools, pill menu): Social posts for X/Facebook/LinkedIn/Telegram-WhatsApp with post/share buttons; Text a friend (5 SMS-sized messages with Text it / WhatsApp / Telegram / Copy); Email swipes (short, standard, long, follow-up); Banners in every standard ad size plus 1080x1080, 1080x1920 and 1280x720 (download or copy URL); the member's Banner wall link; an Objection handling bank (truth + ready-to-send reply); a Videos tab (hook videos in production). Every piece carries the member's invite link; angle links add ?v=instant|adspend|free|ledger. Members who want copy in their own voice can use mybrandedvoice.com. +- INVITE PAGES: a member's link linkspin-test.saasy.top/join/ opens a lead-capture page (email first, wallet later); add ?v=instant|adspend|free|ledger|two for an angle-matched headline. New free members get a short getting-started email series over the first week (unsubscribe link in every email; the admin edits the sequence in /admin > Settings). +- WALLET DRAIN WARNING: Trust Wallet hard-blocks any purchase that spends most of the wallet's POL ("this transaction will drain your wallet", only "Stop and go back"). It is a balance-proportion heuristic, not a contract issue (there are no token approvals; a buy is one native-POL payable call). Advice: smaller package first, or add POL so the buy is well under half the balance, or use MetaMask/Phantom/SafePal (no hard block). The dashboard warns Trust Wallet users before sending when a buy would use more than ~55% of the balance; other wallets are not prompted. After a blocked attempt the WalletConnect session may be dead: Wallet tab > Disconnect > Connect again. +- WALL OWNERSHIP LADDER: the public wall has 3 positions. Position 1 = the member's line banner. Positions 2 and 3 show upline banners (then house ads) UNTIL the member earns them: 2 qualifying buyers ($20+) unlock position 2, 5 unlock position 3; a fully qualified member's wall is 100% their own links/offers (set in Profile > Your wall: label, https link, optional banner image). Empty unlocked slots fall back to upline banners, then house ads. +- LINE BANNER (free, set in Profile): every member can set a destination URL (must allow framing) plus an optional banner image. It is shown to their next THREE levels of new members during welcome tours (position 1 for directs, 2, 3 below), and on their public BANNER WALL at /wall/ — a shareable page showing their line ladder with their join link. Free viral traffic that compounds as the team grows; no credits spent. +- Ad packages: Micro $5/500 credits, Activation $20/2,000, Builder $50/5,500, Growth $100/12,000, Leader $250/32,500. Dollar-priced, settled in POL (Polygon) at the live Chainlink rate. 1 credit = 1 cent of ad delivery. +- Live formats: display banners (per impression), text ads (per impression), full-screen LOGIN ADS (per day: right after a member signs in they land on a sponsor interstitial — they click "Open Ad", the advertiser's page opens in a NEW tab, a countdown runs on the interstitial, and at zero a "Go to dashboard" button appears. Just a CTA link is enough; an optional banner image can be the clickable creative. No framing requirement since it opens in its own tab), WATCH-TO-EARN VIDEO ADS (advertiser uploads an MP4/WebM or gives a direct https .mp4/.webm link and picks a required watch length — 10s/30s/60s — which sets the per-view price; viewers watch in an escape-proof player under Earn credits > Watch videos, the watch time is enforced on the server clock, and they earn credits per completed watch; you never see your own videos), and solo ads. Banner ads also require a size (standard IAB sizes like 728x90, 300x250). Coming: featured rotation with disclosed rotation size, verified-visit packs. +- WALLETS + BUYING POL (Training > Wallets and buying POL, /wallets, members only): preferred MetaMask (recommended; extra accounts for Qualified Start), Phantom (PHANTOM GOTCHA: Polygon is OFF by default; Settings > Active Networks > turn on Polygon, otherwise connect/buy fails or shows the wrong network; seen with Jim Watts' EB team 2026-09-13), SafePal, Coinbase Wallet; Trust works but blocks buys spending most of its POL (keep ~2x). MoonPay flow: connect wallet, Buy packages > "Buy POL with a card" opens MoonPay with POL on Polygon + the member's address prefilled; card/Apple Pay/Google Pay; first-time ID check; minimum order ~$30; buy package cost + 2-3 POL for fees; POL arrives in minutes; then buy. Exchanges: withdraw POL on the Polygon network. Never MATIC on Ethereum, never share the recovery phrase. +- INTRO VIDEO ON THE WALL: Profile > social links has an "Intro video" field (YouTube, Vimeo or direct .mp4 link). It embeds on the member's public wall page (/wall/) right under their bio, above the three-level line and the join button. +- HOLDING TANK (Members > My line > Holding tank card): free members who joined with no sponsor wait there; a member who has switched on payouts AND bought their own $20+ package can Adopt one (first come, max 2 open adoptions, 7-day window; if the person never links a wallet or buys, they fall back into the tank; a person can be adopted twice at most). Adopting sets the sponsor, opens a chat and emails the member; their first purchase then binds to the adopter on-chain. Members can also "Release to tank" one of their own free referrals (pay it forward), but NOT someone they adopted less than 3 days ago: an adoption is a commitment, and a dropped adoption still counts toward that person's two-adoption lifetime limit. Releases are posted to the feed and Telegram like pickups. Admin sees the tank under Members. +- DAILY CLAIM STREAK: finishing the daily ad set and claiming pays 5 credits on day 1, 7 on day 2, 10 from day 3, and 25 on every 7th consecutive day; miss a day and it restarts. After the set, verified visits (up to 20 a day, 1 credit each) keep earning, and the credits are meant to be spent on a campaign. +- BLOG (public, linkspin-test.saasy.top/blog): Marty's coaching and teaching articles on building a line, advertising that pays, and daily habits; each article has its own page and can be shared; RSS at /blog/feed.xml. Members who want to write their own articles: not offered today. +- ACHIEVEMENT BADGES ON TELEGRAM (2026-09-13): when a member unlocks Spark/Surge/Circuit/Nexus, their personalised badge image (username on the ribbon) is posted automatically to the team's Telegram payments topic and the main group, once per badge; members cannot trigger posts themselves (the 'Post to Telegram' button is admin-only); 'Share' opens a picker (X, Facebook, Telegram, WhatsApp, LinkedIn, Massifly (copies the post and opens the feed composer), the phone's own share menu, copy link, save image) that shares the member's public badge page linkspin-test.saasy.top/b//, which shows the badge and their join link. +- PROMO TOOLKIT BY BADGE + AI COPY ENGINE (2026-09-14): Promo tools > AI Copy Engine shows the ladder: Free (links, posts, swipes, banners, wall, objections, shorts, badge pages), Spark (payouts on: one-tap campaign templates aimed at the member's link, printable handout with their QR at /handout/), Surge (first qualifying buyer; the AI Copy Engine is live: posts, DMs, follow-ups, objection replies, emails, story posts, team broadcasts in the member's name with their link, honesty rules built in; 20 free generations a month), Circuit (60 free; Video Maker renders every promo video and short with the member's own end card and QR, hosted for download; split tester compares join angles by views, joins, buyers), Nexus (150 free; Leader Ops: three-level team triage with stalled flags and one-click nudges, AI-drafted team broadcasts, credit grants from the leader's earned pool to anyone in their line, co-branded join page showing the sponsor's bio, and the member's own partner code: welcome credits funded from the leader's pool at each redemption, plus a partner kit page /partners?ref=&promo=CODE). After the free allowance each generation costs 10 ad credits from the earned pool. Credits are advertising, never money. +- MEMBER UPDATE EMAILS (2026-09-14, admin only): Admin > Releases > Email an update to members: pick release notes, add an intro, choose an audience (newsletter opt-ins, opt-ins active in the last 30 days, or everyone including those who declined), preview, send a test to the admin, then send; plain text from no-reply@linkspin-test.saasy.top with an opt-out link; the log shows sent/opted out/failed. +- LINKED POSITIONS AND BADGES (2026-09-14): qualifying buyers on a member's linked positions count toward their achievement badges (Spark/Surge/Circuit/Nexus) and light their chip on their sponsor's line, and a badge once earned never regresses. CONTRACT RULE: levels 2 and 3 are paid to the MAIN wallet only when that wallet's own count of qualifying buyers reaches 2 and 5; buyers registered under a linked position count for that position, not the main wallet. Qualified Start positions buy UNDER the main wallet, so those buys raise the main count; a member whose old wallet was swapped out (its buyers stay with the old position) needs new $20+ buyers under the current main wallet before level 2 pays it. The contract still pays levels 2 and 3 to each position based on that position's OWN qualifying buyers, so the Qualifying buyers tile shows the main position's count with the linked positions' count underneath. +- CREDITS VS POL (2026-09-15, Marty's stance): EVERY member earns CREDITS (viewing, videos, visits, inbox, sign-in bonus, badge bonuses); credits are ad delivery, not money. POL is paid by the contract to a member's wallet when referrals buy packages. ALWAYS describe activation as 'the $20 starter package plus payouts switched on' and lead every earning answer with it; do NOT volunteer that level 1 can pay without a purchase. If someone asks point-blank whether a purchase is required for level-1 payouts, answer truthfully in one sentence: the contract pays level 1 to any member whose payouts are switched on, and the $20 package is what makes them a qualifying buyer for their sponsor, funds their own ads, and unlocks badges, tools, the holding tank and wall positions. A $20+ purchase by a referral makes that referral a qualifying buyer for their sponsor; 2 and 5 open levels 2 and 3. Never claim the payout itself is gated by a purchase. +- PIPELINE (coming soon, built 2026-09-15; opens when the site setting pipelineMode is on): a follow-up board on the dashboard, tab 'Pipeline' between My line and Buy packages. Columns: Talking to, Joined, Wallet linked, Payouts on, Bought, Building, Later. Prospects (from the My line prospect list) and directs are placed automatically from what they have actually done (coaching rung, on-chain buys); nobody drags cards. The sponsor adds a note, a follow-up date and a tag (hot, later, no response, not interested); a 'Follow up today' strip lists due cards; stalled cards (quiet 3+ days) are flagged; each card carries the message for its exact stage with 'Open chat with this message' (member chat) or copy. Until it opens, the tab shows a coming-soon card with the roadmap date. +- PAYMENT + MISSED-PAYMENT NOTICES (2026-09-15): every payout that lands (who bought, level, share in POL and dollars, transaction link) and every missed payout is delivered BOTH by email and as an on-site inbox message from the company account, shown in the login pop-up and the Messages card, so it is waiting when the member signs in. MISSED-PAYOUT detail: when a level-2 or level-3 share passes a member by because that level is not open on their account, the member gets an email the same minute: who bought, the POL and dollar amount they missed, how many qualifying buyers they have versus the 2 or 5 needed, and the two ways to close the gap (bring buyers, or Qualified Start). Qualified members whose wallet rejected a transfer get a different email telling them to link a regular wallet. +- LAUNCH WEEK SWIPES (2026-09-15): the founding-week checklist page (/launch) ends with four promoter emails members send to their OWN lists, one a day toward Monday's opening, with the member's invite link and the FOUNDER code (500 credits for anyone who joins before Mon 2026-09-21 9 AM Central) filled in, each with a Copy button. These are swipes for members, not emails the site sends. +- LOGIN ADS CHARGE ONLY ON DAYS SHOWN (2026-09-15): the login ad daily fee (100 credits) is charged only for days the ad was actually shown at least once; with many login ads sharing the sign-ins, an ad that was not picked that day pays nothing. Featured links now count views (one per viewer per hour) as well as clicks. +- SPONSOR HOLD (2026-09-14): if a member's account names a sponsor that cannot be paid right now (sponsor has not switched on payouts, or the chain lookup failed), the first purchase and the payouts activation are held with a message instead of silently crediting the company. Fix: the sponsor switches on payouts, or the member retries; the admin gets a Telegram alert. +- NO-PAYOUT POSITIONS (2026-09-14): admin can list member ids (Settings > No-payout positions) whose wallets must never be paid again (e.g. a compromised key). Those positions stay linked for history and counting only: the buy-from picker disables them, a purchase from them is refused, and invite links that would place a new member under them (or under their upline chain) route to the fallback sponsor instead. The contract itself cannot change a member's wallet. +- LINE AT A GLANCE, ALL LEVELS (2026-09-14): a gold chip on any level means that person made their $20+ buy (a qualifying buyer for their own sponsor); a small number on the chip is how many qualifying buyers of their own they have. My line rows say the same in words. Leaders use it to see who is one buyer short and encourage them. +- MY LINE SPONSOR LINE (2026-09-14): every row on My line, all three levels, shows who sponsored that person ("sponsored by @name", or "you" for directs). +- MY LINE ACTIVITY DROP-DOWN (2026-09-14): every row on My line, all three levels, has an Activity button that opens that person's activity: last seen, joined, stage and next step, ads viewed today and claim streak, campaigns active, link views in 30 days and joins, directs and qualifying buyers, wallet/payouts, badges, and a Working or Quiet verdict. Read-only, phone friendly. +- GETTING STARTED CARD (2026-09-14): the top of every new member's Overview shows four steps (username, link wallet, switch on payouts, first package) with the current one explained and one button that opens the right tab and highlights the control (Wallet tab > Link wallet; then Activate payouts). Encouragement only: it can be hidden, and members who only want to view ads and earn credits are never blocked. It disappears once all four are done. +- LEADERBOARD + REFERRAL CONTEST (2026-09-14): linkspin-test.saasy.top/leaderboard ranks members by ad packages sold to people they directly sponsor (dollar value, read from the chain; a member's own linked positions and second accounts never count). Periods: this week (Monday to Sunday, Central time), this month, all time. Weekly and monthly winners are recorded automatically at rollover, announced in Telegram, and the credit prizes set by the admin (a ladder: 1st 1,000 / 2nd 500 / 3rd 250 weekly, 5,000 / 2,500 / 1,000 monthly by default) are granted automatically to those positions; the prize text is shown on the page and on the Overview's Leaderboard card, which also shows the member's own rank. Prizes are credits or packages, never cash. +- WHAT'S NEW / ROADMAP (2026-09-14): linkspin-test.saasy.top/whats-new lists release notes (what shipped, dated, tagged new/improved/fixed) and the roadmap (planned / building, with rough ETAs). The Overview has a "What's new" card with the latest three notes and what is being built; a dot marks notes since the member's last look. Written by the admin in Admin > Releases. +- HOLDING TANK ALERTS: when new members land in the tank, a note at the top of every member's Overview names them (usernames) and a post goes to the team's Telegram payments topic; adopt from My line > Holding tank (your own $20 package required). +- LEGACY WELCOME (former Faucet Wave / Tier One Ads members): they join through linkspin-test.saasy.top/from/faucetwave or linkspin-test.saasy.top/from/tieroneads and, if their email is on the legacy list, welcome-back credits are added automatically at signup (former advertisers 500, former earners 150; once per person; credits, not POL). They land in the holding tank like any member who joins without a sponsor. +- PROMO CODES: partner site owners get a reusable code; a member redeems it on a join link (?promo=CODE) or in the Overview box "Have a promo code?" and receives free ad credits (amount set per code by the admin, one use per account; codes can cap uses or expire). Credits, not POL. +- DORMANT-LEAD RESCUE: a FREE referral (no wallet, no purchase) with no message from their sponsor for 10 days triggers a warning email + dashboard flag to the sponsor ("unreached, tank in N days"); at 14 days (warning at least 4 days old) the lead moves to the holding tank and the sponsor is told. Sponsor resets the clock with a chat, a Nudge, or the "Contacted them" button (for phone/text contact). Leads whose sponsor link resolves to nobody go to the tank after a day. Nothing on-chain moves; anyone bound by a purchase never moves. +- PIF (pay it forward) button: on a free direct or an adopted member who has linked a wallet, the sponsor taps PIF, enters an amount (suggested: the $20 package plus fees), and their OWN wallet app opens with the member's address prefilled; the POL goes wallet to wallet. The site never touches the funds; it only logs the transaction and tells the recipient with a Polygonscan link. The gift is theirs; nothing forces a purchase. +- FOUNDING WEEK / PRE-LAUNCH (Training > Founding week checklist, /launch, members only): eight items read live from the account: username, wallet linked, payouts on, level 2 qualified (2 buyers of $20+, or Qualified Start with 2 linked positions), the leader play = level 3 (5 qualifying buyers, up to 5 linked positions; then buy from the main wallet), line banner, links + play chosen (self-marked), first two placed. Reason: unqualified levels pass up, so leaders qualify BEFORE their teams' teams buy. Countdown shows when admin sets launchAt. Never call the site 'pre-launch' publicly: it is live and paying. +- COUNTRY TARGETING (2026-09-11): New campaign form has "Show to" tier checkboxes (Tier 1 = US, CA, GB, AU, NZ, IE, DE, FR, NL, SE, NO, DK, FI, CH, AT, BE by default; Tier 2 = rest of Western/Central Europe, JP, KR, SG, HK, TW, IL, Gulf, ZA, BR, MX, AR, CL, CO, etc.; Tier 3 = everyone else; lists editable in Admin > Settings geoTier1/geoTier2). Country comes from the viewer's IP (DB-IP lite). Applies to on-site delivery of every format; a narrowed banner/text ad is NOT sent to the partner network (which is worldwide). Unknown country never matches a narrowed campaign. Campaign rows show 'tier 1+2' chips and top viewer countries. +- SCHEDULING (2026-09-11): any campaign except featured can take an optional start and end time (local time) in the New campaign form; solo ads label it "Send from" (inbox deliveries begin then). A scheduled campaign shows "scheduled" until it starts; at the end it shows "ended" and the unspent budget returns to Available. Banner/text scheduled campaigns join the partner network at their start time. There is NO dayparting (hours-of-day targeting) by design; the daily cap paces budgets. Each campaign row shows a small views-by-hour chart (on-site views, viewer's local time, last 7 days). +- BALANCE RULE (members ask this a lot): a balance is what is NOT committed to a live campaign. Starting a campaign sets aside its whole budget at once (earned pool first, then purchased), so Available drops once and stays still while ads serve; the budget spends down inside Members > Campaigns. Dashboard shows Purchased available, Earned available, In live campaigns. A paused campaign keeps its unspent budget set aside so it can resume. A low balance with a live campaign is not lost credits. Refunds/comps of purchased money are credited off-chain as purchased-grade credits: shown under Purchased as "credited to you", fund anything incl. login ads. Viewing-earned credits never fund login ads. +- 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. +- Transparency: live ledger at /ledger streams every chain event, and EVERY transaction row carries a clickable "verify" link — to the public block explorer when the chain has one, or to the built-in transaction viewer at /tx/ (block, time, from/to, value, gas, decoded events, read live from the node). Plain-language contract review at /contract; the verified source is public. +- The contract cannot be paused, upgraded, or drained. The operator can only manage the ad catalog ($1-$500 bounds, 24h price timelock) and rotate keys. + +RULES: +- 1 to 4 sentences, plain text, no markdown, include full URLs when pointing at a page. +- NEVER promise or estimate income. This is an advertising service with a referral program, never an investment. Be honest about risk: crypto transactions are irreversible, nobody earns unless real ad purchases happen. +- No em dashes. If you do not know, say so and point to /contract or the ledger.`; +} + +function askAI(message, historyText) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ + model: MODEL, + max_tokens: 300, + messages: [ + { role: 'system', content: systemPrompt() }, + ...(historyText ? [{ role: 'user', content: 'Earlier context: ' + historyText.slice(0, 800) }] : []), + { role: 'user', content: String(message).slice(0, 600) } + ] + }); + const req = https.request({ hostname: 'openrouter.ai', path: '/api/v1/chat/completions', method: 'POST', + headers: { Authorization: 'Bearer ' + key(), 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) }, timeout: 30000 }, + res => { + let d = ''; + res.on('data', c => d += c); + res.on('end', () => { + try { resolve(JSON.parse(d).choices[0].message.content.trim()); } + catch (e) { reject(new Error('openrouter ' + res.statusCode + ': ' + d.slice(0, 150))); } + }); + }); + req.on('error', reject); + req.on('timeout', () => req.destroy(new Error('openrouter timeout'))); + req.end(body); + }); +} + +async function answer(message, historyText) { + const msg = String(message || '').trim(); + if (!msg) return { error: 'Say something first.' }; + for (const c of CANNED) if (c.re.test(msg)) return { reply: c.a, canned: true }; + if (!enabled()) return { reply: 'Good question. The quick answers I have cover packages, payouts, qualification, and joining; for anything deeper, the plain-language contract review at https://linkspin-test.saasy.top/contract covers most of it.' }; + try { return { reply: await askAI(msg, historyText) }; } + catch (e) { + console.error('chat AI failed', e.message); + return { reply: 'I hit a snag answering that one. The contract review at https://linkspin-test.saasy.top/contract and the live ledger at https://linkspin-test.saasy.top/ledger cover most deep questions.' }; + } +} + +module.exports = { init, answer, enabled }; diff --git a/coach.js b/coach.js new file mode 100644 index 0000000..5aabcb4 --- /dev/null +++ b/coach.js @@ -0,0 +1,292 @@ +// Coaching layer: where each member sits on the ladder, what to say to them, +// automatic nudges to stalled members, a weekly digest to their sponsor, a +// prospect list per member, and per-angle link stats. Dual-mode store like the +// other modules (MySQL when DATABASE_URL is set, JSON files on the volume otherwise). +const fs = require('fs'); +const path = require('path'); +const db = require('./db'); + +let DATA_DIR = null, chain = null, accounts = null, mailer = null; +const DAY = 86400000; + +// ---- the ladder (same rungs the Overview stepper and achievements use) ---- +const RUNGS = [ + { n: 0, key: 'joined', label: 'Joined', next: 'Link a wallet', + say: 'Hey {{name}}, quick one: link your wallet on the Wallet tab (one free signature, it cannot move funds). That is what lets payouts reach you. Two minutes, and I am here if you get stuck.', + mail: ['Your LinkSpin wallet is not linked yet', 'You joined LinkSpin but no wallet is linked yet, so nothing can pay you. Open the Wallet tab and press Connect and link wallet. One free signature, it cannot move funds. Then switch on payouts and you are set for good.'] }, + { n: 1, key: 'wallet', label: 'Wallet linked', next: 'Switch on payouts', + say: 'Wallet is linked, nice. Next is Switch on payouts (Wallet tab, one free transaction). Do it before anyone under you buys, so nothing passes you by.', + mail: ['One free step left: switch on payouts', 'Your wallet is linked. One free transaction on the Wallet tab switches on payouts, and from then on every purchase in your line can pay you in the same transaction it happens. Do it before your people start buying: the contract locks each buyer to their sponsor at their first purchase.'] }, + { n: 2, key: 'payouts', label: 'Payouts on', next: 'Buy a first package ($20+ counts)', + say: 'You are set to earn. Fastest way to see it work: the $20 Activation package. It counts as a qualifying buy for your sponsor and gives you 2,000 credits to run your first ad.', + mail: ['See a payout land: your first package', 'Payouts are on. The fastest way to see the whole thing work is the $20 Activation package on Buy packages: 2,000 credits for your own ads, and your sponsor gets paid the second it settles, on a public ledger you can open yourself.'] }, + { n: 3, key: 'bought', label: 'Bought $20+', next: 'Share the link with one person', + say: 'Everything is on. Now send your invite link to one person today. Promo tools has a ready-to-send message. Who is the first person you thought of?', + mail: ['Send your link to one person today', 'Everything on your account is switched on. Nobody has joined your link yet. Open Promo tools, copy the ready-to-send message, and send it to one person today. One conversation a day is the whole job.'] }, + { n: 4, key: 'first', label: 'First buyer', next: 'One more buyer opens level 2', + say: 'First buyer, congrats. One more $20+ buyer opens level 2 for you. Want a shortcut? Qualified Start under Buy packages lets you be your own second buyer.', + mail: ['One more buyer opens level 2', 'You have your first qualifying buyer. One more $20+ buyer opens level 2 (20% on every package your directs\' people buy). If you would rather not wait, Qualified Start on Buy packages lets you be your own second buyer with a wallet you own.'] }, + { n: 5, key: 'level2', label: 'Level 2 open', next: 'Three more buyers open level 3', + say: 'Level 2 is open. Three more qualifying buyers open level 3 and the Nexus badge. Which of your people is closest?', + mail: ['Three more buyers open level 3', 'Level 2 is open on your account. Five qualifying buyers open level 3 and the Nexus badge, and every package on all three levels pays you. Which of your people is closest? Message them from My line.'] }, + { n: 6, key: 'level3', label: 'Level 3 open', next: 'Coach your directs to their two', + say: 'Fully qualified. Now the multiplier: teach your directs the same three steps. Their buyers are your level 2 and 3.', + mail: null } +]; +function rungFor(acct, mm) { + if (!acct || !acct.address) return 0; + if (!acct.memberId) return 1; + if (!mm || !mm.countedAsBuyer) return 2; + const bc = mm.buyerCount || 0; + if (bc === 0) return 3; + if (bc === 1) return 4; + if (bc < 5) return 5; + return 6; +} +const memberCache = new Map(); +async function member(id) { + if (!id) return null; + const c = memberCache.get(id); + if (c && Date.now() - c.t < 60000) return c.v; + let v = null; try { v = await chain.member(id); } catch (e) {} + memberCache.set(id, { t: Date.now(), v }); + return v; +} +const STALL_DAYS = 3; +async function describe(acct, now = Date.now()) { + const mm = await member(acct.memberId); + const rung = rungFor(acct, mm); + const last = Math.max(acct.created || 0, acct.lastSeen || 0); + const quietDays = Math.floor((now - last) / DAY); + const stalled = rung < 6 && quietDays >= STALL_DAYS; + const R = RUNGS[rung]; + return { rung, label: R.label, next: R.next, say: R.say, quietDays, stalled, buyerCount: mm ? mm.buyerCount : 0, + counted: !!(mm && mm.countedAsBuyer), joined: acct.created, lastSeen: acct.lastSeen || 0, onchainSponsorId: mm ? mm.sponsorId : null }; +} +// coaching view for a sponsor: every direct, ladder rung, stalled flag, what to say +async function coachView(email) { + const levels = await accounts.downline(email, 1); + const directs = levels.length ? levels[0].members : []; + const me = await accounts.byEmail(email); + const myId = me ? (me.memberId || 0) : 0; + const now = Date.now(); + const out = []; + for (const d of directs) { + const c = await describe(d, now); + // bound: the contract pays whoever the member registered under. A direct who activated with + // no sponsor (or a different one) is in this line on the site but pays this member nothing. + c.bound = c.onchainSponsorId == null ? null : (myId > 0 && c.onchainSponsorId === myId); + c.free = !d.memberId; // still free: can be released to the holding tank (pay it forward) + c.address = (!d.memberId && d.address) ? d.address : null; // linked wallet of a free direct: the PIF gift target + out.push(Object.assign({ email: d.email, name: d.username ? '@' + d.username : (d.memberId ? 'member #' + d.memberId : d.email.replace(/^(.).*(@.*)$/, '$1***$2')), memberId: d.memberId || 0 }, c)); + } + // most actionable first: stalled lowest rung, then quiet days + out.sort((a, b) => (Number(b.stalled) - Number(a.stalled)) || (a.rung - b.rung) || (b.quietDays - a.quietDays)); + return { directs: out, stalled: out.filter(x => x.stalled).length, rungs: RUNGS.map(r => ({ n: r.n, label: r.label, next: r.next })) }; +} + +// ---- stores ---- +const J = { + db: { v: 1, nudges: {}, digests: {}, prospects: [], nextId: 1, views: [] }, + FILE: () => path.join(DATA_DIR, 'coach.json'), + load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} }, + save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }, + async lastNudge(email) { return this.db.nudges[email] || null; }, + async setNudge(email, rung, ts) { this.db.nudges[email] = { rung, ts }; this.save(); }, + async lastDigest(email) { return this.db.digests[email] || 0; }, + async setDigest(email, ts) { this.db.digests[email] = ts; this.save(); }, + async prospects(email) { return this.db.prospects.filter(p => p.owner === email).sort((a, b) => (a.nextTs || 9e15) - (b.nextTs || 9e15)); }, + async saveProspect(email, p) { + if (p.id) { const cur = this.db.prospects.find(x => x.id === p.id && x.owner === email); if (!cur) return null; Object.assign(cur, p, { updated: Date.now() }); this.save(); return cur; } + const row = Object.assign({}, p, { id: this.db.nextId++, owner: email, created: Date.now(), updated: Date.now() }); this.db.prospects.push(row); this.save(); return row; + }, + async removeProspect(email, id) { const n = this.db.prospects.length; this.db.prospects = this.db.prospects.filter(x => !(x.id === Number(id) && x.owner === email)); this.save(); return n !== this.db.prospects.length; }, + async addView(token, angle, ts, ref) { this.db.views.push({ token, angle, ts, ref: ref || '' }); if (this.db.views.length > 50000) this.db.views = this.db.views.slice(-40000); this.save(); }, + async addClick(campaignId, src, ts) { this.db.clicks = this.db.clicks || []; this.db.clicks.push({ campaignId, src, ts }); if (this.db.clicks.length > 50000) this.db.clicks = this.db.clicks.slice(-40000); this.save(); }, + async clicksFor(ids) { return (this.db.clicks || []).filter(c => ids.includes(c.campaignId)); }, + async views(tokens, since) { return this.db.views.filter(v => tokens.includes(v.token) && v.ts >= since); }, + async viewsSince(since) { return this.db.views.filter(v => v.ts >= since); } +}; +const D = { + async lastNudge(email) { const r = await db.q('SELECT rung, ts FROM nudges WHERE email=?', [email]); return r[0] ? { rung: r[0].rung, ts: Number(r[0].ts) } : null; }, + async setNudge(email, rung, ts) { await db.q('INSERT INTO nudges (email,rung,ts) VALUES (?,?,?) ON DUPLICATE KEY UPDATE rung=VALUES(rung), ts=VALUES(ts)', [email, rung, ts]); }, + async lastDigest(email) { const r = await db.q('SELECT ts FROM digests WHERE email=?', [email]); return r[0] ? Number(r[0].ts) : 0; }, + async setDigest(email, ts) { await db.q('INSERT INTO digests (email,ts) VALUES (?,?) ON DUPLICATE KEY UPDATE ts=VALUES(ts)', [email, ts]); }, + async prospects(email) { + const rows = await db.q('SELECT * FROM prospects WHERE owner_email=? ORDER BY COALESCE(next_ts, 9e15), created', [email]); + return rows.map(r => ({ id: r.id, owner: r.owner_email, name: r.name, contact: r.contact, status: r.status, note: r.note, nextTs: r.next_ts ? Number(r.next_ts) : null, created: Number(r.created), updated: Number(r.updated) })); + }, + async saveProspect(email, p) { + if (p.id) { + await db.q('UPDATE prospects SET name=?, contact=?, status=?, note=?, next_ts=?, updated=? WHERE id=? AND owner_email=?', [p.name, p.contact, p.status, p.note, p.nextTs || null, Date.now(), Number(p.id), email]); + return (await this.prospects(email)).find(x => x.id === Number(p.id)) || null; + } + const r = await db.q('INSERT INTO prospects (owner_email,name,contact,status,note,next_ts,created,updated) VALUES (?,?,?,?,?,?,?,?)', [email, p.name, p.contact, p.status, p.note, p.nextTs || null, Date.now(), Date.now()]); + return (await this.prospects(email)).find(x => x.id === r.insertId) || null; + }, + async removeProspect(email, id) { const r = await db.q('DELETE FROM prospects WHERE id=? AND owner_email=?', [Number(id), email]); return r.affectedRows > 0; }, + async addView(token, angle, ts, ref) { await db.q('INSERT INTO join_views (token,angle,ts,ref) VALUES (?,?,?,?)', [token, angle, ts, ref || null]); }, + async addClick(campaignId, src, ts) { await db.q('INSERT INTO click_sources (campaign_id,src,ts) VALUES (?,?,?)', [Number(campaignId), src, ts]); }, + async clicksFor(ids) { if (!ids.length) return []; const rows = await db.q('SELECT campaign_id, src, ts FROM click_sources WHERE campaign_id IN (' + ids.map(() => '?').join(',') + ')', ids); return rows.map(r => ({ campaignId: r.campaign_id, src: r.src, ts: Number(r.ts) })); }, + async views(tokens, since) { + if (!tokens.length) return []; + const rows = await db.q('SELECT token, angle, ts, ref FROM join_views WHERE ts>=? AND token IN (' + tokens.map(() => '?').join(',') + ')', [since, ...tokens]); + return rows.map(r => ({ token: r.token, angle: r.angle, ts: Number(r.ts), ref: r.ref || '' })); + }, + async viewsSince(since) { + const rows = await db.q('SELECT token, angle, ts, ref FROM join_views WHERE ts>=?', [since]); + return rows.map(r => ({ token: r.token, angle: r.angle, ts: Number(r.ts), ref: r.ref || '' })); + } +}; +const impl = () => db.enabled() ? D : J; + +// ---- prospects (a member's own "people I've talked to" list) ---- +const STATUSES = ['new', 'contacted', 'interested', 'joined', 'bought', 'not now']; +function cleanProspect(b) { + const s = v => String(v || '').trim().slice(0, 120); + const status = STATUSES.includes(String(b.status || '').toLowerCase()) ? String(b.status).toLowerCase() : 'new'; + const nextTs = b.nextTs ? Number(b.nextTs) : (b.next ? Date.parse(b.next) : null); + return { id: b.id ? Number(b.id) : 0, name: s(b.name), contact: s(b.contact), status, note: String(b.note || '').trim().slice(0, 400), nextTs: nextTs && !isNaN(nextTs) ? nextTs : null }; +} +async function prospects(email) { return impl().prospects(String(email).toLowerCase()); } +async function saveProspect(email, body) { + const p = cleanProspect(body || {}); + if (!p.name) return { error: 'Give the prospect a name.' }; + if (!p.id) { const n = (await prospects(email)).length; if (n >= 500) return { error: 'That is a lot of prospects. Archive some first.' }; } + const row = await impl().saveProspect(String(email).toLowerCase(), p); + return row ? { ok: true, prospect: row } : { error: 'No such prospect.' }; +} +async function removeProspect(email, id) { return (await impl().removeProspect(String(email).toLowerCase(), id)) ? { ok: true } : { error: 'No such prospect.' }; } + +// ---- link stats: views per angle (join page loads), joins and buyers per angle ---- +// referring domain from a Referer header: our own pages count as 'direct', empty is 'direct' +function refHost(referer) { + try { const h = new URL(String(referer || '')).hostname.replace(/^www\./, '').toLowerCase(); return (!h || /linkspin\.com$/.test(h)) ? 'direct' : h.slice(0, 80); } catch (e) { return 'direct'; } +} +async function recordView(token, angle, referer) { try { await impl().addView(String(token || '').toLowerCase().slice(0, 40), String(angle || '').toLowerCase().slice(0, 20), Date.now(), refHost(referer)); } catch (e) {} } +// where an ad click happened: our own surface (by page path) or an outside host +function clickSource(referer) { + try { + const u = new URL(String(referer || '')); const h = u.hostname.replace(/^www\./, '').toLowerCase(); + if (!/linkspin\.com$/.test(h)) return h.slice(0, 80) || 'unknown'; + const p = u.pathname; + if (p.startsWith('/view')) return 'ad viewer'; if (p.startsWith('/my')) return 'member area'; if (p.startsWith('/ledger')) return 'live ledger'; + if (p.startsWith('/wall/')) return 'member walls'; if (p.startsWith('/shorts')) return 'shorts'; if (p.startsWith('/plays')) return 'plays page'; if (p === '/' || p === '') return 'home page'; + return 'site'; + } catch (e) { return 'unknown'; } +} +async function recordClick(campaignId, referer) { try { await impl().addClick(Number(campaignId), clickSource(referer), Date.now()); } catch (e) {} } +async function clickSources(campaignIds) { + const rows = await impl().clicksFor(campaignIds.map(Number)); + const out = {}; + for (const r of rows) { out[r.campaignId] = out[r.campaignId] || {}; out[r.campaignId][r.src] = (out[r.campaignId][r.src] || 0) + 1; } + return out; +} +async function linkStats(email) { + const a = await accounts.byEmail(email); + if (!a) return { angles: [] }; + const tokens = [a.code, a.username, a.memberId ? String(a.memberId) : null].filter(Boolean).map(t => String(t).toLowerCase()); + const now = Date.now(); + const views = await impl().views(tokens, 0); + const joined = await accounts.listByReferrer(tokens); + const ANG = ['', 'instant', 'adspend', 'free', 'ledger', 'two']; + const rows = {}; + for (const k of ANG) rows[k] = { angle: k || 'plain', views30: 0, views: 0, joins: 0, buyers: 0 }; + const src = {}; + const srcRow = k => (src[k] = src[k] || { source: k, views30: 0, views: 0, joins: 0, buyers: 0 }); + for (const v of views) { const r = rows[v.angle] || rows['']; r.views += 1; if (now - v.ts < 30 * DAY) r.views30 += 1; const s = srcRow(v.ref || 'direct'); s.views += 1; if (now - v.ts < 30 * DAY) s.views30 += 1; } + for (const j of joined.slice(0, 300)) { + const r = rows[String(j.joinedVia || '').toLowerCase()] || rows['']; + r.joins += 1; + const s = srcRow(j.joinedRef || 'direct'); s.joins += 1; + if (j.memberId) { const mm = await member(j.memberId); if (mm && mm.countedAsBuyer) { r.buyers += 1; s.buyers += 1; } } + } + const sources = Object.values(src).sort((a, b) => (b.views + b.joins * 5) - (a.views + a.joins * 5)); + return { angles: Object.values(rows), sources, totalViews: views.length, totalJoins: joined.length }; +} + +// ---- automatic nudges to stalled members + weekly digest to sponsors ---- +let ticking = false; +const NUDGE_GAP = 5 * DAY; +async function nudgeTick() { + if (ticking || !mailer || !mailer.hasKey()) return { nudges: 0, digests: 0 }; + ticking = true; + let nudges = 0, digests = 0; + try { + const now = Date.now(); + const all = await accounts.listAll(5000); + for (const acct of all) { + if (!acct.email || !acct.created || now - acct.created < STALL_DAYS * DAY) continue; + const c = await describe(acct, now); + if (!c.stalled) continue; + const R = RUNGS[c.rung]; if (!R.mail) continue; + const last = await impl().lastNudge(acct.email); + if (last && (last.rung === c.rung || now - last.ts < NUDGE_GAP)) continue; // one email per rung, never more than one every 5 days + const spon = await accounts.sponsorOf(acct.email); + const who = spon ? (spon.username ? '@' + spon.username : 'your sponsor') : null; + const body = R.mail[1] + '\n\nOpen your dashboard: https://linkspin-test.saasy.top/my' + (who ? '\n\nStuck? Message ' + who + ' from My line, that is what they are there for.' : '') + '\n\nLinkSpin'; + try { await mailer.send(acct.email, R.mail[0], body); await impl().setNudge(acct.email, c.rung, now); nudges += 1; } + catch (e) { console.error('nudge', acct.email, e.message); } + if (nudges >= 40) break; // spread the load across ticks + } + // weekly member email (Marty, 2026-09-14, modelled on the mailer.gold weekly): credits sitting unspent, + // the streak, the contest standings with the member's own rank, the tank, and the line for sponsors + let lbWeek = null; try { lbWeek = X.lb ? await X.lb().view('week') : null; } catch (e) {} + let tankN = 0; try { tankN = X.tank ? (await X.tank.waiting()).length : 0; } catch (e) {} + for (const acct of all) { + if (!acct.email) continue; + const lastD = await impl().lastDigest(acct.email); + if (now - lastD < 7 * DAY) continue; + if (now - (acct.lastSeen || acct.created || 0) > 45 * DAY) { await impl().setDigest(acct.email, now); continue; } // gone quiet for six weeks: leave them to the nudges + const view = await coachView(acct.email); + // audience: sponsors with directs (as before) unless the admin switched the weekly on for every member + const everyone = X.siteConfig && String(X.siteConfig().memberWeeklyEmail || '0') === '1'; + if (!everyone && !view.directs.length) { await impl().setDigest(acct.email, now); continue; } + const name = acct.username ? '@' + acct.username : 'there'; + const parts = ['Hi ' + name + ', your week on LinkSpin:']; + // credits + try { + const ids = [acct.memberId, ...(await accounts.positions(acct.email)).map(p => p.memberId)].filter(Boolean); + const bal = await X.ads.balances(ids, acct.email); + if (bal.available > 0) parts.push('- You have ' + bal.available.toLocaleString() + ' ad credits sitting unspent. That is ' + bal.available.toLocaleString() + ' cents of delivery doing nothing. Campaigns > New campaign, point it at your invite link: https://linkspin-test.saasy.top/my#campaigns'); + else if (bal.inCampaigns > 0) parts.push('- All ' + bal.inCampaigns.toLocaleString() + ' of your credits are working in live campaigns. Good.'); + } catch (e) {} + // streak + try { + const st = await X.ads.viewStatus(acct.email); + if (st.streakDay > 1) parts.push('- Your claim streak is on day ' + st.streakDay + '. Today\'s claim pays ' + (st.claimCredits || 0) + ' credits; miss a day and it restarts at 5.'); + else parts.push('- Five ads and a claim a day is the free way in: 5, 7, 10, then 25 credits every seventh day in a row: https://linkspin-test.saasy.top/my#earn'); + } catch (e) {} + // contest + if (lbWeek && lbWeek.top) { + const me = lbWeek.top.find(r => r.name === '@' + acct.username) || null; + let mine = me; if (!mine) { try { mine = (await X.lb().view('week', acct.email)).me; } catch (e) {} } + parts.push('- Referral contest this week (' + (lbWeek.prize || 'credits to the top 3') + '): ' + (lbWeek.top.length ? lbWeek.top.slice(0, 3).map(r => r.rank + '. ' + r.name + ' (' + r.sales + ' sold)').join(', ') : 'no sales yet, first sale takes the top spot') + '.' + + (mine ? ' You are #' + mine.rank + ' with ' + mine.sales + ' sold.' : ' You are not on the board yet; one $20 package bought by someone you sponsor puts you there.') + ' https://linkspin-test.saasy.top/leaderboard'); + } + // tank + if (tankN > 0 && acct.memberId) parts.push('- ' + tankN + ' member' + (tankN === 1 ? ' is' : 's are') + ' waiting for a sponsor in the holding tank. Adopt one from My line: https://linkspin-test.saasy.top/my#line'); + // line (sponsors only) + if (view.directs.length) { + const week = view.directs.filter(d => now - d.joined < 7 * DAY).length; + parts.push('- Your line: ' + view.directs.length + ' direct' + (view.directs.length === 1 ? '' : 's') + ', ' + week + ' joined this week, ' + view.stalled + ' gone quiet.'); + const actions = view.directs.filter(d => d.stalled).slice(0, 3).map(d => ' Message ' + d.name + ': "' + d.say.replace('{{name}}', d.name.replace(/^@/, '')) + '"'); + if (actions.length) parts.push(actions.join('\n')); + } + parts.push('\nTwenty minutes, in order: the set, one message to your line, the tank, one conversation outward. https://linkspin-test.saasy.top/blog/the-twenty-minute-day\n\nLinkSpin · https://linkspin-test.saasy.top/my\nNo income is guaranteed. Credits are advertising, not money.'); + const subject = lbWeek && lbWeek.top && lbWeek.top.length && lbWeek.top[0].name === '@' + acct.username ? 'You are #1 this week on LinkSpin' : 'Your week on LinkSpin: credits, streak, contest'; + try { await mailer.send(acct.email, subject, parts.join('\n')); digests += 1; } catch (e) { console.error('digest', acct.email, e.message); } + await impl().setDigest(acct.email, now); + if (digests >= 30) break; + } + } finally { ticking = false; } + return { nudges, digests }; +} + +let X = {}; // extra refs for the weekly member email (ads, tank, lb getter), 2026-09-14 +function init(opts) { X = arguments[0] || {}; + DATA_DIR = opts.dataDir; chain = opts.chain; accounts = opts.accounts; mailer = opts.mailer; + J.load(); +} +async function viewsSince(since) { return impl().viewsSince(since); } +module.exports = { init, RUNGS, rungFor, describe, coachView, prospects, saveProspect, removeProspect, STATUSES, recordView, linkStats, nudgeTick, recordClick, clickSources, refHost, viewsSince }; diff --git a/db.js b/db.js new file mode 100644 index 0000000..8322471 --- /dev/null +++ b/db.js @@ -0,0 +1,320 @@ +// MySQL data layer (Marty: real concurrency over flat files). +// DATABASE_URL present -> mysql2 pool, schema bootstrap, one-time import of +// any existing volume JSON (accounts/sessions/ +// campaigns) so nothing is lost on the switch. +// DATABASE_URL absent -> db.enabled() is false and the modules fall back to +// their JSON stores (local dev keeps working). +// The chain index stays a file: it is a rebuildable cache of the blockchain. +const fs = require('fs'); +const path = require('path'); + +let pool = null; +let DATA_DIR = null; + +function enabled() { return !!process.env.DATABASE_URL; } + +async function init(opts) { + DATA_DIR = opts.dataDir; + if (!enabled()) return false; + const mysql = require('mysql2/promise'); + pool = mysql.createPool(process.env.DATABASE_URL + '?connectionLimit=10&charset=utf8mb4'); + await bootstrap(); + await importJsonOnce(); + console.log('db: MySQL connected, schema ready'); + return true; +} +const q = async (sql, params) => (await pool.query(sql, params))[0]; + +async function bootstrap() { + await q(`CREATE TABLE IF NOT EXISTS accounts ( + email VARCHAR(190) PRIMARY KEY, + pass VARCHAR(200) NULL, + sponsor_ref VARCHAR(32) NOT NULL DEFAULT '', + code VARCHAR(16) NOT NULL UNIQUE, + address VARCHAR(64) NULL UNIQUE, + created BIGINT NOT NULL, + last_seen BIGINT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS sessions ( + token VARCHAR(80) PRIMARY KEY, + email VARCHAR(190) NULL, + address VARCHAR(64) NULL, + member_id INT NOT NULL DEFAULT 0, + expires BIGINT NOT NULL, + INDEX (expires) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS campaigns ( + id INT AUTO_INCREMENT PRIMARY KEY, + owner_email VARCHAR(190) NOT NULL, + member_id INT NOT NULL, + type VARCHAR(12) NOT NULL, + name VARCHAR(80) NOT NULL, + target_url VARCHAR(500) NOT NULL, + image_url VARCHAR(500) NULL, + title VARCHAR(80) NULL, + body VARCHAR(200) NULL, + budget INT NOT NULL, + spent INT NOT NULL DEFAULT 0, + accrued INT NOT NULL DEFAULT 0, + imps INT NOT NULL DEFAULT 0, + clicks INT NOT NULL DEFAULT 0, + batch_imps INT NOT NULL DEFAULT 0, + status VARCHAR(12) NOT NULL DEFAULT 'active', + last_day_charged VARCHAR(10) NULL, + created BIGINT NOT NULL, + INDEX (owner_email), INDEX (type, status), INDEX (member_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS earned_credits ( + email VARCHAR(190) PRIMARY KEY, + balance INT NOT NULL DEFAULT 0, + granted_welcome TINYINT NOT NULL DEFAULT 0, + updated BIGINT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + const alterSafe0 = async sql => { try { await q(sql); } catch (e) { if (!['ER_DUP_FIELDNAME', 'ER_DUP_KEYNAME'].includes(e.code)) throw e; } }; + await alterSafe0('ALTER TABLE earned_credits ADD COLUMN milestones VARCHAR(255) NULL'); // comma-joined granted milestone keys + await alterSafe0('ALTER TABLE earned_credits ADD COLUMN purchase_grade INT NOT NULL DEFAULT 0'); // part of the pool that is refunded/credited PURCHASED money: shows as purchased, funds login ads + await alterSafe0('ALTER TABLE earned_credits ADD COLUMN login_day CHAR(10) NULL'); // last daily-login-bonus day + await alterSafe0('ALTER TABLE earned_credits ADD COLUMN login_streak INT NOT NULL DEFAULT 0'); // consecutive-day streak + // additive columns (MySQL 8 has no IF NOT EXISTS for columns) + const alterSafe = async sql => { + try { await q(sql); } + catch (e) { if (!['ER_DUP_FIELDNAME', 'ER_DUP_KEYNAME'].includes(e.code)) throw e; } + }; + await alterSafe('ALTER TABLE promo_codes ADD COLUMN funder VARCHAR(190) NULL'); // member-funded partner codes (Nexus), 2026-09-14 + await alterSafe('ALTER TABLE accounts ADD COLUMN username VARCHAR(30) NULL'); + await alterSafe('ALTER TABLE accounts ADD UNIQUE KEY uq_username (username)'); + await alterSafe('ALTER TABLE accounts ADD COLUMN member_id INT NULL'); + await alterSafe('ALTER TABLE accounts ADD KEY idx_member (member_id)'); + await alterSafe('ALTER TABLE accounts ADD COLUMN avatar_url VARCHAR(500) NULL'); + await alterSafe('ALTER TABLE accounts ADD COLUMN bio VARCHAR(600) NULL'); + await alterSafe('ALTER TABLE accounts ADD COLUMN socials VARCHAR(1200) NULL'); // JSON {platform:url} + await alterSafe('ALTER TABLE accounts ADD COLUMN line_banner_url VARCHAR(500) NULL'); + await alterSafe('ALTER TABLE accounts ADD COLUMN line_target_url VARCHAR(500) NULL'); + await q(`CREATE TABLE IF NOT EXISTS daily_views ( + email VARCHAR(190) NOT NULL, + day CHAR(10) NOT NULL, + views INT NOT NULL DEFAULT 0, + claimed TINYINT NOT NULL DEFAULT 0, + last_ts BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (email, day) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await alterSafe('ALTER TABLE daily_views ADD COLUMN video_count INT NOT NULL DEFAULT 0'); // watch-to-earn videos/day + await q(`CREATE TABLE IF NOT EXISTS solo_inbox ( + id INT AUTO_INCREMENT PRIMARY KEY, + campaign_id INT NOT NULL, + email VARCHAR(190) NOT NULL, + delivered BIGINT NOT NULL, + read_ts BIGINT NULL, + visited_ts BIGINT NULL, + rewarded TINYINT NOT NULL DEFAULT 0, + rewarded_day CHAR(10) NULL, + UNIQUE KEY uq_solo (campaign_id, email), + INDEX (email), INDEX (email, rewarded, rewarded_day) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await alterSafe('ALTER TABLE solo_inbox ADD COLUMN visited_ts BIGINT NULL'); + // solo message bodies are sanitized rich text; TEXT gives them room + await alterSafe('ALTER TABLE campaigns MODIFY body TEXT NULL'); + await alterSafe('ALTER TABLE campaigns ADD COLUMN cta_label VARCHAR(40) NULL'); + await alterSafe('ALTER TABLE campaigns ADD COLUMN last_shown_day VARCHAR(10) NULL'); // login ads: charge only days they were actually shown (2026-09-15) + await alterSafe('ALTER TABLE campaigns ADD COLUMN width INT NULL'); // banner size (IAB) → also NAS width + 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 alterSafe('ALTER TABLE campaigns ADD COLUMN expires BIGINT NULL'); // featured rotation end time + await alterSafe('ALTER TABLE campaigns ADD COLUMN starts BIGINT NULL'); // featured run start (booked day); any type: scheduled start + await q(`CREATE TABLE IF NOT EXISTS camp_hours ( + campaign_id INT NOT NULL, day CHAR(10) NOT NULL, hour TINYINT NOT NULL, n INT NOT NULL DEFAULT 0, + PRIMARY KEY (campaign_id, day, hour) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // on-site views per UTC hour, for the by-hour chart + await alterSafe('ALTER TABLE campaigns ADD COLUMN house TINYINT NOT NULL DEFAULT 0'); // admin house ad: free, never charged + await alterSafe('ALTER TABLE campaigns ADD COLUMN daily_cap INT NULL'); // optional credits/day pacing (banner + text) + await alterSafe('ALTER TABLE campaigns ADD COLUMN geo VARCHAR(16) NULL'); // country tiers the campaign shows to ('1,2'); NULL = everyone + await q(`CREATE TABLE IF NOT EXISTS camp_geo ( + campaign_id INT NOT NULL, cc CHAR(2) NOT NULL, n INT NOT NULL DEFAULT 0, + PRIMARY KEY (campaign_id, cc) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // on-site serves per viewer country + await alterSafe('ALTER TABLE campaigns ADD COLUMN day_spent INT NOT NULL DEFAULT 0'); + await alterSafe("ALTER TABLE campaigns ADD COLUMN day_key CHAR(10) NULL"); + // linked positions: extra wallets owned by one email account (Qualified Start). + // Each is its own on-chain member sponsored by the account's main member. + await q(`CREATE TABLE IF NOT EXISTS positions ( + address VARCHAR(64) PRIMARY KEY, + email VARCHAR(190) NOT NULL, + member_id INT NOT NULL DEFAULT 0, + created BIGINT NOT NULL, + INDEX (email) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS nudges (email VARCHAR(190) PRIMARY KEY, rung INT NOT NULL, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS digests (email VARCHAR(190) PRIMARY KEY, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS promo_codes ( + code VARCHAR(24) NOT NULL PRIMARY KEY, credits INT NOT NULL, partner VARCHAR(80) NULL, note VARCHAR(200) NULL, + max_uses INT NOT NULL DEFAULT 0, expires BIGINT NOT NULL DEFAULT 0, active TINYINT NOT NULL DEFAULT 1, created BIGINT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // partner promo codes -> free ad credits + await q(`CREATE TABLE IF NOT EXISTS promo_redemptions ( + code VARCHAR(24) NOT NULL, email VARCHAR(190) NOT NULL, credits INT NOT NULL, via VARCHAR(12) NOT NULL, ts BIGINT NOT NULL, + PRIMARY KEY (code, email) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS page_hits ( + day CHAR(10) NOT NULL, host VARCHAR(80) NOT NULL, path VARCHAR(40) NOT NULL, n INT NOT NULL DEFAULT 0, + PRIMARY KEY (day, host, path) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // admin Traffic tab: public page views by referring domain + await q(`CREATE TABLE IF NOT EXISTS blog_posts ( + slug VARCHAR(80) NOT NULL PRIMARY KEY, title VARCHAR(140) NOT NULL, excerpt VARCHAR(300) NULL, body MEDIUMTEXT NULL, cover VARCHAR(300) NULL, + tags VARCHAR(200) NULL, status VARCHAR(12) NOT NULL DEFAULT 'draft', author VARCHAR(80) NULL, created BIGINT NOT NULL, updated BIGINT NOT NULL, + published_at BIGINT NULL, views INT NOT NULL DEFAULT 0 + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // public blog articles written in Admin > Blog + await q(`CREATE TABLE IF NOT EXISTS adoptions ( + id INT AUTO_INCREMENT PRIMARY KEY, + adoptee VARCHAR(190) NOT NULL, adopter VARCHAR(190) NOT NULL, + ts BIGINT NOT NULL, expires BIGINT NOT NULL, status VARCHAR(12) NOT NULL DEFAULT 'open', + note VARCHAR(600) NULL, closed BIGINT NULL, + INDEX (adoptee), INDEX (adopter), INDEX (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // holding-tank adoptions + await q(`CREATE TABLE IF NOT EXISTS lead_marks ( + \`lead\` VARCHAR(190) PRIMARY KEY, sponsor VARCHAR(190) NULL, contacted_ts BIGINT NULL, warned_ts BIGINT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // dormant-lead rescue: manual "contacted" marks + warning sent + await q(`CREATE TABLE IF NOT EXISTS prospects ( + id INT AUTO_INCREMENT PRIMARY KEY, + owner_email VARCHAR(190) NOT NULL, + name VARCHAR(120) NOT NULL, + contact VARCHAR(120) NULL, + status VARCHAR(20) NOT NULL DEFAULT 'new', + note VARCHAR(400) NULL, + next_ts BIGINT NULL, + created BIGINT NOT NULL, + updated BIGINT NOT NULL, + INDEX (owner_email) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS pipeline_notes ( + owner_email VARCHAR(190) NOT NULL, + person VARCHAR(190) NOT NULL, + note VARCHAR(1000) NULL, + follow_up BIGINT NULL, + tag VARCHAR(20) NULL, + updated BIGINT NOT NULL, + PRIMARY KEY (owner_email, person) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS join_views ( + id INT AUTO_INCREMENT PRIMARY KEY, + token VARCHAR(40) NOT NULL, + angle VARCHAR(20) NOT NULL DEFAULT '', + ts BIGINT NOT NULL, + INDEX (token, ts) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await alterSafe('ALTER TABLE join_views ADD COLUMN ref VARCHAR(80) NULL'); // referring domain + await q(`CREATE TABLE IF NOT EXISTS click_sources ( + id INT AUTO_INCREMENT PRIMARY KEY, + campaign_id INT NOT NULL, + src VARCHAR(80) NOT NULL, + ts BIGINT NOT NULL, + INDEX (campaign_id, ts) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS visit_seen ( + campaign_id INT NOT NULL, + email VARCHAR(190) NOT NULL, + day CHAR(10) NOT NULL, + ts BIGINT NOT NULL, + UNIQUE KEY uq_visit (campaign_id, email), + INDEX (email, day) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + 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`); + // 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 video_seen ( + email VARCHAR(190) NOT NULL, + campaign_id INT NOT NULL, + day CHAR(10) NOT NULL, + ts BIGINT NOT NULL, + UNIQUE KEY uq_vseen (email, campaign_id, day), + INDEX (email, day) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS ad_reports ( + id INT AUTO_INCREMENT PRIMARY KEY, + campaign_id INT NOT NULL, + reporter VARCHAR(190) NOT NULL DEFAULT '', + reason VARCHAR(20) NOT NULL, + note VARCHAR(600) NULL, + ts BIGINT NOT NULL, + resolved TINYINT NOT NULL DEFAULT 0, + INDEX (resolved, ts), INDEX (campaign_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS burns ( + id VARCHAR(32) PRIMARY KEY, + member_id INT NOT NULL, + amount INT NOT NULL, + ref VARCHAR(64) NOT NULL, + ts BIGINT NOT NULL, + burned_tx VARCHAR(80) NULL, + burned_at BIGINT NULL, + INDEX (burned_tx) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + // follow-up email sequence queue (one row per free account) + await q(`CREATE TABLE IF NOT EXISTS drips ( + email VARCHAR(190) PRIMARY KEY, + step INT NOT NULL DEFAULT 0, + next_at BIGINT NOT NULL, + started BIGINT NOT NULL, + stopped TINYINT NOT NULL DEFAULT 0, + ref VARCHAR(64) NULL, + angle VARCHAR(20) NULL, + INDEX (stopped, next_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await alterSafe('ALTER TABLE accounts ADD COLUMN joined_via VARCHAR(20) NULL'); // ?v= angle the lead came in on + await alterSafe('ALTER TABLE accounts ADD COLUMN joined_ref VARCHAR(80) NULL'); // referring domain of the first join-page visit + await alterSafe('ALTER TABLE accounts ADD COLUMN wall_offers VARCHAR(2000) NULL'); // JSON [{title,bannerUrl,targetUrl}] for wall positions 2-3 (unlock at 2 / 5 qualifying buyers) +} + +// one-time import: only when the tables are empty and JSON files exist +async function importJsonOnce() { + const [{ n }] = await q('SELECT COUNT(*) n FROM accounts'); + if (n > 0) return; + try { + const a = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'accounts.json'), 'utf8')); + for (const acc of Object.values(a.byEmail || {})) { + await q('INSERT IGNORE INTO accounts (email,pass,sponsor_ref,code,address,created) VALUES (?,?,?,?,?,?)', + [acc.email, acc.pass || null, String(acc.sponsorRef || acc.sponsorId || ''), acc.code, acc.address || null, acc.created || Date.now()]); + } + console.log('db: imported', Object.keys(a.byEmail || {}).length, 'account(s) from JSON'); + } catch (e) {} + try { + const c = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'campaigns.json'), 'utf8')); + for (const cp of c.campaigns || []) { + await q(`INSERT IGNORE INTO campaigns (owner_email,member_id,type,name,target_url,image_url,title,body, + budget,spent,accrued,imps,clicks,batch_imps,status,last_day_charged,created) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [cp.owner, cp.memberId, cp.type, cp.name, cp.targetUrl, cp.imageUrl || null, cp.title || null, cp.body || null, + cp.budget, cp.spent || 0, cp.accrued || 0, cp.imps || 0, cp.clicks || 0, cp.batchImps || 0, + cp.status, cp.lastDayCharged || null, cp.created || Date.now()]); + } + for (const b of c.burnsPending || []) { + await q('INSERT IGNORE INTO burns (id,member_id,amount,ref,ts,burned_tx,burned_at) VALUES (?,?,?,?,?,?,?)', + [b.id, b.memberId, b.amount, b.ref, b.ts, b.burnedTx || null, b.burnedAt || null]); + } + console.log('db: imported', (c.campaigns || []).length, 'campaign(s) from JSON'); + } catch (e) {} + try { + const s = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'sessions.json'), 'utf8')); + for (const [tok, sess] of Object.entries(s)) { + if (sess.expires > Date.now()) { + await q('INSERT IGNORE INTO sessions (token,email,address,member_id,expires) VALUES (?,?,?,?,?)', + [tok, sess.email || null, sess.address || null, sess.memberId || 0, sess.expires]); + } + } + } catch (e) {} +} + +module.exports = { init, enabled, q: (...a) => q(...a) }; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7506e74 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + linkspin: + build: . + restart: unless-stopped + environment: + NODE_ENV: production + PORT: 3000 + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + DATA_DIR: /app/data + volumes: + - linkspin-data:/app/data + ports: + - "3000:3000" +volumes: + linkspin-data: diff --git a/docs/PARTNER-KIT.md b/docs/PARTNER-KIT.md new file mode 100644 index 0000000..b9d84ec --- /dev/null +++ b/docs/PARTNER-KIT.md @@ -0,0 +1,68 @@ +# LinkSpin Partner Kit (site owners) + +*Plain-text version of the shareable page. Written for site owners who already know Marty and already run a downline builder.* + +Video overview (4 min 46 s): https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/partner-overview.mp4 + +## Why I built it + +You know I've been running ad sites for years, the kind your members already know: buy a package, run banners and text ads, click for credits. Two of mine, Faucet Wave and Tier One Ads, ran on a licensed script. The vendor went out of business, their license server went dark, and it crippled licenses that were fully paid. The sites died overnight and nothing I could do would bring them back. + +So I built LinkSpin from scratch as part of the Crypto Team Build Network. No vendor, no license server, and the part that always went wrong on ad sites, the money, is handled by a verified smart contract on Polygon instead of by me. When a package sells, the contract splits the payment and sends it in the same transaction. I never hold member funds, so there is no back office, no payday, and nothing anyone can switch off. + +## What LinkSpin is + +An advertising platform where the ad spend in your line pays you. Members join free with an email address, no password and no wallet on day one. They earn credits by viewing ads and can run their first campaign for zero dollars. When they want more reach they buy an ad package, and every package that sells is split by the contract the moment it sells. + +- Seven ad formats: banners, text ads, login ads, solo ads to member inboxes, video, featured links and verified visits. Views are timed on the server. +- Banner and text ads also push out to Network Ad Space, a partner rotation across other member sites. +- Daily caps, country tiers, start and end scheduling, and a balance rule that reserves a campaign's budget when it starts. +- Full WalletConnect integration: members link MetaMask, Trust, Phantom, SafePal or any WalletConnect wallet with one tap and one free signature, mobile or desktop, and buy or get paid straight from that wallet. No custody on my side. +- Every payout is a public transaction on Polygon. Splits and qualification rules are constants in a verified contract the operator cannot change. + +## How the money moves + +Every package splits the same way, in the same transaction: 50% direct sponsor, 20% level 2, 10% level 3, 20% platform. On a $20 package: $10, $4, $2, $4. A level with no qualified member passes its share up to the next qualified person above. + +Qualification is earned, never bought. Every direct buyer pays their sponsor 50% from their first package. Two qualifying buyers ($20 or larger) open level 2, five open level 3. + +| Package | Price | Credits | +|---|---|---| +| Micro | $5 | 500 | +| Activation (the qualifying buy) | $20 | 2,000 | +| Builder | $50 | 5,500 | +| Growth | $100 | 12,000 | +| Leader | $250 | 32,500 | + +## What your members get + +- A free start: join by email, view ads, earn credits, run a real campaign for nothing. +- Your promo credits on top, added the moment they join with your code. +- Instant, public payouts in POL to their own wallet, checkable on Polygonscan. +- Tools: invite message, social posts, email swipes, a banner kit in every size, objection answers, a public profile wall. +- Training videos and written team-building plays. +- A holding tank so members who arrive without a sponsor get adopted by qualified builders. +- Coaching built in: next-move card, nudges, and a live payments topic on Telegram. + +## What you get for listing it in your builder + +- Your spot at the top: you join directly under the company at the top, no sponsor in between, via the company link Marty sends you. Claim it by activating your account with at least the $20 package. +- Your own promo code: reusable, adds free ad credits per redemption, optional cap and expiry, one use per account, every redemption logged and visible. +- Your own line: your join link in your builder puts every member under you; each buyer pays you 50% of their first package and every one after, and their buyers open your level 2 and 3 shares. +- Ready-made creatives: banners in 468x60, 728x90, 300x250, 160x600, 120x600, 1200x630, text ad copy, email swipes, a program description. +- A bridge page for your brand on request, in LinkSpin's design, carrying your code. +- Attribution you can check: signups and buyers are tagged with their source. + +## Setting it up + +1. Join with the company link Marty sends you (top placement, no sponsor in between) and pick your username. Your link: linkspin-test.saasy.top/join/yourname +2. Send Marty your username and the site. He mints your code (for example YOURSITE) with the agreed credits. +3. Add LinkSpin to your builder with https://linkspin-test.saasy.top/join/yourname?promo=YOURSITE +4. Use the banners and text ads from the kit. Existing members type the code into "Have a promo code?" on their Overview. +5. Link a wallet, switch on payouts, and activate with at least the $20 package to claim your spot at the top. + +## The honest part + +LinkSpin sells advertising. Members earn from the ad packages people in their line buy, and nothing else. There is no earn-without-referring option, on purpose. No income is guaranteed, results depend on effort, and cryptocurrency involves risk of loss. The contract, the ledger and every payout are public. + +Contact: Marty Bostick, marty@marketingwithmarty.com, linkspin-test.saasy.top, t.me/cryptoteambuild diff --git a/docs/PROMO-ANGLE-VIDEOS-PLAN.md b/docs/PROMO-ANGLE-VIDEOS-PLAN.md new file mode 100644 index 0000000..5b72236 --- /dev/null +++ b/docs/PROMO-ANGLE-VIDEOS-PLAN.md @@ -0,0 +1,69 @@ +# LinkSpin angle videos + squeeze pages (plan, 2026-09-09) + +Same machine RM Circle runs: a short hook video per angle, a matched squeeze +page at `linkspin-test.saasy.top/join/?v=`, a card in Promo tools with the +matched link, and paste-ready copy. Draft for Marty's review. Nothing here is +built yet except what "Already in place" lists. + +## Already in place + +- Join links: `/join/` set the sponsor cookie and 302 to the home page. No `?v=` handling yet. +- Promo tools pane: personalized posts, one email swipe, banner wall link, banner images. No videos, no matched links. +- Home page has the signature visuals to reuse: the animated ledger mockup (member #7 buys, level 1/2/3 payouts land), the 3-generation isometric viz, the package ladder. +- Free distribution for the finished videos: the video ad format on the site itself (house ads, no cost) and NAS syndication for banner/text. +- Video pipeline from RM Circle: HyperFrames (Node 22) + ElevenLabs "Marty Normal Voice", build.py timing from VO files, lint, render, loudnorm, compress, hash-named under `public/v/`. + +## Contract facts every script must stay inside + +- Packages: $5 / $20 / $50 / $100 / $250 mint 500 / 2,000 / 5,500 / 12,000 / 32,500 credits. One credit = one cent of ad delivery. +- Split on every purchase, in the same Polygon transaction: 50% direct sponsor, 20% level 2, 10% level 3, 20% platform. Paid to wallets, nothing to claim. +- Qualification to receive: level 1 pays any activated sponsor; level 2 needs 2 qualifying buyers ($20+); level 3 needs 5. +- Seven ad formats: banner, text, login, solo, video, featured link, verified visits. Members earn credits by viewing. +- Join is free by email. Wallet comes out at purchase or when switching payouts on. +- Dollar package prices are fixed constants and may be spoken. POL amounts and POL/USD are never baked into audio or frames (evergreen rule). +- Every video ends on the disclaimer card: "No income guaranteed. Results depend on your effort. Crypto involves risk of loss." + +## The five angles + +| key | working title | hook (first line) | for whom | shape | proof beat | +|---|---|---|---|---|---| +| `instant` | Paid before the page reloads | "What if your commission landed before the thank-you page finished loading?" | everyone; flagship | PAS, ~60s | ledger rows animate: purchase, three payouts, same block | +| `adspend` | You were buying traffic anyway | "Every ad dollar you have ever spent went one direction. Out." | marketers who buy ads | AIDA, ~55s | package ladder + "your buyers' ad spend pays your line" | +| `free` | Watch first, spend never | "You can run your first ad campaign here for exactly zero dollars." | tire kickers, earn-side | PAS, ~50s | earn credits by viewing, welcome credits, first campaign | +| `ledger` | No back office. No payday. | "Your last affiliate program paid you on the 15th. If it paid you." | skeptics, burned affiliates | PAS, ~55s | Polygonscan / live ledger as the payroll | +| `two` | Two buyers open level two | "Two buyers. Then five. That is the whole ladder." | builders | AIDA, ~55s | 3-generation viz lights up 50 / 20 / 10 | + +Recommended first three: `instant`, `adspend`, `free`. `ledger` and `two` follow once the page mechanism is live. Landscape 16:9 for the squeeze page; a 9:16 cut of each for social if Marty wants it. + +## Squeeze page mechanics + +- `/join/?v=` serves `join.html`: hook headline, video with poster, three proof chips (same transaction, public ledger, join free), "You're joining under @username", inline email join. Plain `/join/` keeps redirecting to the home page. +- Sponsor cookie set exactly as today. The `v` also rides into the account (`sponsor_ref` stays as is; add `joined_via`) so we can see which hook converts. +- Per-angle og/twitter tags with `og:url` INCLUDING `?v=` (Facebook canonicalizes shares to og:url; RM Circle lost its variants once this way). +- Promo tools: one card per video with preview, "Copy matched link", download buttons for both cuts, a post per network, an SMS-length message, and share buttons (Text, WhatsApp, Telegram, Copy). +- Each finished video also goes live as a house video ad and its squeeze page as a house login ad. + +## Production pipeline (mirrors RM Circle) + +1. Scripts: written from this brief through the bv-tester1 engine with the contract facts pasted in, then checked line by line against the facts above. No em dashes, no income claims, no POL figures. +2. Project per video at `D:\Projects\HighRisk\onchain-ad-membership\video-` (assets/scenes.json, tts.cjs, build.py, index.html), same as `video-phone`. +3. Silent draft first, rendered with the site palette (mint #43e8c3 on #0b1512, Sora display, mono ledger rows). Marty approves visuals and copy. +4. Voice: ElevenLabs clone by default, or Marty records to a teleprompter script (6 files, vo1 to vo6). Timing re-derives from the audio either way. +5. Render 1080p30, loudnorm to -14 LUFS, compress (crf 26, faststart), hash-name into `public/v/`, poster frame, og image per angle. +6. Ship the squeeze page, promo cards, house ads, and the chatbot answer update together. + +## Order of work + +1. Marty picks the first three angles and the voice route. +2. Five scripts drafted and verified; Marty approves. +3. Squeeze page + og tags + promo cards shipped with poster placeholders, so links work before videos exist. +4. Silent drafts, approval, voiced finals, deploy. +5. House ads placed, launch posts in Marty's voice on massifly and Telegram. + +## Decisions for Marty + +- Which three angles first? (Recommendation above.) +- Voice clone or your own recording? +- Squeeze page CTA: inline email join (recommended) or send to the home page? +- Do you want 9:16 cuts for socials from day one? +- Scripts through the Branded Voice engine, or written directly in your style? diff --git a/docs/TEAM-BUILDING-PLAYS.md b/docs/TEAM-BUILDING-PLAYS.md new file mode 100644 index 0000000..c2419eb --- /dev/null +++ b/docs/TEAM-BUILDING-PLAYS.md @@ -0,0 +1,84 @@ +# LinkSpin team-building plays + +Working doc, 2026-09-09. Three ways to build a line, written so a member can pick one and run it. Every number below comes from the live contract and rate table (50 / 20 / 10 / 20 split; qualifying buyer = a direct who buys a $20+ package; level 2 opens at 2 qualifying, level 3 at 5; milestone credits 10 / 25 / 50 / 100; featured strip 40 credits a day; one broadcast a day; wall position 2 at 2 qualifying, position 3 at 5). + +## The one rule under all three plays + +Unqualified levels pass up. If someone on your level 2 buys before you have 2 qualifying buyers, that 20% does not wait for you. It goes to the next qualified sponsor above you (or the platform). Same for level 3 and 5. So whatever play you run, the first job is the same: get qualified before your line gets busy. The dashboard "Your next move" card is the ladder; the plays are how you climb it. + +## Play 1: Wide and teach ("the fifty play") + +Who it fits: someone with an audience, a list, a group, or traffic they can point somewhere. Time-rich or reach-rich. + +Why it works: every direct who buys is 50% to you, instantly, forever. Directs are the only thing that qualifies you. And the teaching is what fills levels 2 and 3 without you doing anything extra: your directs' buyers are your 20%, their buyers are your 10%. + +The move: +1. One new conversation a day, minimum. Use the Text a friend and Social posts in Promo tools; every piece already carries your link. +2. Point paid traffic at an angle lander, not the bare link: `?v=adspend` for advertisers, `?v=free` for freebie seekers, `?v=instant` for the crypto-curious. +3. Every new direct gets the same three sentences from you inside 24 hours (sponsor chat or one broadcast): "Pick your username. Link your wallet and switch on payouts. Send your link to one person today." That is the whole teaching. They teach it to their people. +4. Run the network's own ads at your link: buy a package, or claim the daily 5 credits, and spend the credits on a Featured link (40 credits a day) or a text ad pointed at your angle lander. Recruits from inside the network already understand the product. + +Scoreboard: Joined your line (should climb daily), Qualifying buyers (2 then 5), then watch level 2 and 3 rows appear in My line. + +Ceiling: none on width. Weakness: shallow lines churn if you skip step 3. + +## Play 2: Two, then down ("the depth play") + +Who it fits: someone with a small circle who would rather coach two people well than pitch twenty. + +Why it works: two qualifying buyers open level 2 (20%), position 2 on your wall, the Circuit badge, and 50 bonus credits. From there, every person your two bring in pays you 20%, and every person those people bring in pays you 10% (once you reach 5). Your effort goes into two relationships instead of a funnel. + +The move: +1. Get two directs to a $20+ package. Sit with them on the buy if you have to (Trust Wallet needs a POL cushion; SafePal or MetaMask are smoother). +2. Coach them to their two. Sponsor chat daily for the first week. One broadcast a day to your directs with a single ask each time. +3. Set your line banner to your team's meeting place (Telegram group, a training page). Every new member three levels down meets it on their welcome tour. That is how your coaching reaches people you never directly recruited. +4. Keep adding directs until you have five. This is the catch in the depth play: level 3 only opens on five qualifying directs of your own. Two deep, coached well, gets you a healthy 20% level. It does not get you the 10% level. + +Scoreboard: Qualifying buyers 2, then level 2 count in My line rising, then your directs' own Qualifying buyers (ask them, or read their wall page). + +Ceiling: level 2 income until you personally hit five. Strength: the stickiest lines come from this play. + +## Play 3: Five and wide ("the combination", recommended default) + +Who it fits: anyone willing to do both. This is the play the dashboard ladder is actually built for. + +The move, in order: +1. Sprint to five qualifying directs. Nothing else matters until level 3 is open: that is Nexus, wall position 3 (your whole public page runs your own links), 100 bonus credits, and the full 50/20/10. +2. Then split the day. Mornings wide: one new conversation, one post, one ad running. Evenings deep: read My line, message the three newest directs, send the broadcast. +3. Coach the 2-then-5 rule down the line. Each of your five gets pushed to two (your level 2 fills), then to five (your level 3 fills). Use the achievements Share links; people copy what they see rewarded. +4. Book the featured strip for 7 days whenever you have 280 credits spare. Ten slots a day, every member sees it. + +Scoreboard: all four tiles, plus Earning levels on the Overview (buyers referred, level open, how many to next). + +## Opening move: Qualified Start (works with any play) + +Qualification is earned by buyers, never bought, but you can be your own first buyers, openly. Under Buy packages, link a second wallet you own as a position. When it buys a $20 package the contract counts it as a qualifying buyer, half the purchase returns to your main wallet, and its credits pool with yours. Two positions open level 2 the same day (about $20 net for $40 of credits); five open level 3 (about $50 net for $100). Say it plainly: your own money, your own wallets, a faster start, never an income promise. + +## Which play fits you + +| You have | Run | +|---|---| +| A list, a group, or ad budget | Wide and teach | +| A few close people and patience | Two, then down | +| An hour a day and a phone | Five and wide | + +## First 30 days (any play) + +- Day 1: username, wallet linked, payouts on, welcome tour done (25 credits). Send the link to one person. +- Days 2 to 7: one conversation a day. Claim the daily 5 credits. First buyer (25 bonus credits). +- Days 8 to 14: second qualifying buyer. Level 2 open. Set your line banner. First broadcast. +- Days 15 to 30: coach the two to their two. Add directs three, four, five. Level 3 open by day 30 is the stretch goal. + +## Messages that fit the plays + +- Wide: "I run ads anyway. This one pays me in the same transaction the buyer's package sells, on a public ledger. Free to join by email: {{link}}" +- Depth: "I need two people who will actually do this with me, not twenty who will look at it. You are one of the two I thought of. {{link}}" +- Combo, to a new direct: "Three things today: username, wallet on, one person. I will check in tomorrow." + +No income is guaranteed. Results depend on your effort. Crypto involves risk of loss. LinkSpin sells advertising; it is not an investment. + +## Where this should live on the site (proposal, not built) + +- Promo tools: a "Plays" pill with the three plays and the fit table. +- Sponsor coaching panel (pending build): the coach sees which play each direct is on, from their buyer count and line shape. +- Training: a short video per play once the plays are approved. diff --git a/drip-defaults.json b/drip-defaults.json new file mode 100644 index 0000000..a384d7e --- /dev/null +++ b/drip-defaults.json @@ -0,0 +1,37 @@ +[ + { + "hours": 24, + "subject": "What you already own on LinkSpin", + "body": "If you created an LinkSpin account yesterday and then got busy, no worries. It happens.\n\nBut I want to circle back on something you may have missed. Right now, sitting in your free account, you already have three things most people never realize they own.\n\n1. Your username is your invite link.\n\nThat free account came with a link that is already set to go. Pick a username, share the link, and anyone who joins through it is in your line. It is yours from day one.\n\n2. Welcome credits that unlock in minutes.\n\nTake the short welcome tour inside your dashboard and the system adds credits to your account. You can use them {{paid:on top of the ad credits you already bought|to run a small test campaign and see how everything works}}.\n\n3. Promo tools with the posts already written.\n\nYour dashboard has a Promo tools section. Inside it: social posts, text messages, email swipes, even banners, all already carrying your invite link. You do not have to write a word.\n\n{{paid:You already jumped in on a package, which is great. The Promo tools make sharing your link the easy part. Everything is set up and ready to go.|This email is not asking you to buy anything. I just wanted to show you what is already in your account, waiting.}}\n\nOne thing to do today: sign in at {{site}}/my, pick your username if you have not yet, and copy your invite link. Just knowing it is there makes everything else easier.\n\nYour link right now: {{link}}\n\nMore tomorrow.\n\nMarty\n\n{{footer}}" + }, + { + "hours": 48, + "subject": "You can watch every payment live", + "body": "A couple of days ago you joined LinkSpin. Free account. Nothing to lose.\n\nMaybe you have looked around. Maybe you have not.\n\nHere is what matters about this platform. The thing that is different from every other traffic system you have seen.\n\nThe money.\n\nWhen anyone on LinkSpin buys an ad package, a verified smart contract on Polygon splits it in the same transaction. No holding. No waiting. Nobody touches the funds before they move.\n\n50 percent goes to their direct sponsor. 20 percent to level two. 10 percent to level three. 20 percent stays with the platform.\n\nEvery cent lands in real wallets within seconds.\n\n{{paid:You have already bought a package, so you have seen this firsthand.|You have not bought a package yet, and here is the thing. You can verify all of this without spending a dime.}}\n\nOpen {{site}}/ledger. Every transaction is on the public ledger. No wallet needed. No login. Just every ad package, every split, every payment, in the order it happened.\n\nScroll through it. Pick a transaction. Follow it.\n\nThis is proof you can check yourself. Not a screenshot. Not a promise in an email. A live blockchain you can inspect right now.\n\nThe money does not sit in a company wallet waiting to be released. It just moves. Instantly. Permanently. On-chain.\n\nSpend a few minutes on the ledger. Then you will understand why this model works differently.\n\nYour link: {{link}}\n\nMarty\n\n{{footer}}" + }, + { + "hours": 96, + "subject": "That pending feeling..", + "body": "A few days ago you signed up for LinkSpin.\n\nMaybe you have already bought an ad package. Maybe you have not. Either way, I want to talk about something most of us in this business have gotten way too used to.\n\nThe hold.\n\nYou make a sale. You wait. Your account says pending. Or under review. Or funds released on the 15th. You refresh the page. Nothing. You email support. They say 3 to 5 business days. Or 7.\n\nIt is so normal we do not even question it anymore.\n\nA platform holds your money for days or weeks. They say it is for security. Or processing. Or some other reason that sounds fine until you think about it.\n\nWho is holding it? And what are they doing with it while you wait?\n\nLinkSpin works differently. It runs on a public blockchain called Polygon. When someone buys a package, a verified smart contract splits the payment in that same transaction. 50 percent goes to their sponsor. 20 to level two. 10 to level three. 20 to the platform.\n\nIt lands in real wallets in seconds.\n\nNothing is ever held. There is nothing to withdraw. Every payment is public on the live ledger.\n\n{{paid:You have already seen it happen. Your purchase paid your sponsor's line instantly. No pending. No waiting. No support ticket. It just showed up.|You can see this for yourself right now. The live ledger is public at {{site}}/ledger. Every transaction. Every split. Every payout. No login, no under review. Just a contract that does what it says, every time. And the $5 package is the cheapest way to watch it happen with your own purchase.}}\n\nYour dashboard is waiting at {{site}}/my.\n\nMarty\n\n{{footer}}" + }, + { + "hours": 144, + "subject": "What your free account can actually do..", + "body": "So you signed up for LinkSpin about a week ago.\n\nFree account, no password, no fuss. Maybe you checked the dashboard, thought okay, cool, and moved on.\n\nLet me show you what is actually sitting in that account.\n\nLinkSpin is an ad platform on the Polygon blockchain. In plain English: people buy ad credits, people view ads and earn credits, and the whole thing runs on a smart contract that pays out instantly.\n\nNow, the ads themselves.\n\nSeven formats: banners, text ads, full-screen login ads, solo ads delivered into member inboxes, video ads, featured links, and verified visits.\n\nEvery single view is dwell-timed on the server. That means a real person sat there long enough for it to count. Not bots. Not drive-bys.\n\nOne credit equals one cent of ad delivery. Simple math.\n\nAnd here is the part that makes it work: members earn credits by viewing ads. So when you run a campaign, your ad is shown to people who are actively watching. They have a reason to pay attention.\n\n{{paid:You already bought a package. Good. Your credits are sitting in your account. Go to the Campaigns tab and launch your first real campaign today. Pick a format and let it run. Watch what happens.|You have not bought a package yet, and you do not have to start with one. Sign in at {{site}}/my. Take the welcome tour for your welcome credits, earn more by viewing ads, and launch a small test campaign with those. See how it feels. See the numbers.}}\n\nEither way, the best way to understand this thing is to actually use it.\n\nYour link: {{link}}\n\nMarty\n\n{{footer}}" + }, + { + "hours": 192, + "subject": "The one thing nobody tells you about ad platforms..", + "body": "This might be the most important email I send you about LinkSpin.\n\nBecause there is a detail in how this works that most people do not notice until it is too late.\n\nIt is not about the ad credits, the formats, or even the commission structure.\n\nIt is about timing.\n\nHere is how it works. Your invite link is already live. If someone clicks it, signs up, and buys their first ad package, any package from $5 to $250, you receive 50 percent of that payment. The smart contract pays you in the same transaction. No holding tank, no withdrawal minimum, no waiting.\n\nThat is the upside everyone talks about.\n\nHere is what nobody mentions.\n\nThe moment a person buys their first package through any link, they are locked to that sponsor permanently. Every future package they buy pays that sponsor's line.\n\nSo if someone you know (a friend, a subscriber, a group contact) joins LinkSpin through someone else's link and buys their first package there, they are locked to that other person for good.\n\n{{paid:You already bought, so your own placement is settled. Your link is live and paying.|If you have not bought yet, your link is still live. It still pays you 50 percent from anyone who clicks it today and buys.}}\n\nBut it will not capture the people you know unless you send it to them.\n\nOne person this week. Just put {{link}} where they will see it. That is all it takes.\n\n{{sponsor}} got their link from somewhere too. Same deal. Now it is your turn to pass it forward.\n\nMarty\n\n{{footer}}" + }, + { + "hours": 264, + "subject": "Your link is live (and already loaded)", + "body": "That link of yours? It pays the moment someone buys.\n\n{{paid:You already know the feeling. Someone buys, the smart contract runs, and 50 percent lands in your wallet. In seconds.|It works like this: someone clicks your link, buys any package, and 50 percent of it lands in your wallet in the same transaction. In seconds.}}\n\nNobody has to wait. No holding period. No withdraw button, because nothing was ever held.\n\nThe money splits in the same transaction the buyer makes. It lands in real wallets. And every payment is public on the live ledger.\n\nBut here is what most people do not use right away. The Promo tools tab in your dashboard.\n\nOpen it and you will find social posts, text messages, email swipes, and banners. All carrying your link. All ready to copy and paste.\n\nPlus an objection bank with honest answers for when someone asks how it works.\n\nTwo qualifying buyers ($20 or more) open level two for you: 20 percent of what their people buy. Five open level three.\n\n{{paid:You are already in motion. Now use the tools to keep going.|You have not bought a package yet. That is fine. The tools work either way. Your link is live whether you buy or not.}}\n\nOne text from Promo tools today. That is all it takes to get your first buyer moving.\n\n{{site}}/my\n\nMarty\n\n{{footer}}" + }, + { + "hours": 336, + "subject": "Two weeks in. Here is where you are at.", + "body": "Two weeks since you joined LinkSpin. Thought it was time for a quick recap of where things stand.\n\nYou have a free account that is already working for you. Your invite link is live. Anyone who joins through it is in your line, and locks to you as their sponsor at their first purchase.\n\n{{paid:You grabbed a package and have credits to advertise with. Every ad you run reaches real people with dwell-timed views across banners, text ads, solo inbox ads, video ads and more. Your budget delivers exactly what you paid for.|You have not picked up an ad package yet, and that is fine. Your free account is ready when you are. Packages start at $5.}}\n\nHere is the referral side, exactly as the contract has it. When someone in your line buys a package, a smart contract splits their payment instantly, and 50 percent goes to you as their direct sponsor. No waiting, no withdrawal step.\n\nTwo qualifying buyers (people you referred who bought $20 or more) open level two: 20 percent of what the people they refer buy. Five qualifying buyers open level three: 10 percent from one level deeper.\n\nEvery payment lands in real wallets in seconds and is public on the ledger. Nothing held, nothing to withdraw.\n\nOne more thing those buyers unlock: your public wall. Two qualifying buyers make the second ad position on your wall yours, five make the third. At that point the whole page runs your links and nobody else's.\n\nYour sponsor {{sponsor}} is right there in your dashboard. Open Messages any time.\n\nAnd the Promo tools are loaded: social posts, text messages, email swipes, banners, an objection bank, all with your invite link baked in.\n\nFrom here, the LinkSpin newsletter takes over with updates and new features.\n\nYou know where your dashboard is: {{site}}/my\nYour link: {{link}}\n\nMarty\n\n{{footer}}" + } +] \ No newline at end of file diff --git a/drip.js b/drip.js new file mode 100644 index 0000000..28704dd --- /dev/null +++ b/drip.js @@ -0,0 +1,182 @@ +// Follow-up email sequence for new free members ("the lead came in the door +// with their email"). A row is queued when an account is created; a ticker +// sends each step when it comes due; a signed unsubscribe link stops it. +// Sequence copy lives in DATA_DIR/drip.json (admin-editable) with the +// defaults below. Dual-mode storage like the rest of the site (MySQL / JSON). +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const db = require('./db'); + +let DATA_DIR = null, mailer = null, accounts = null, chain = null, SITE = 'https://linkspin-test.saasy.top'; + +// hours = time after the account was created. Copy drafted through Marty's +// Branded Voice engine (one framework per email: gain, logic, PAS, logic, +// honest fear of loss, AIDA, gain + hand-off to the newsletter), then checked +// line by line against the contract facts. Admin overrides live in drip.json. +const DEFAULT_SEQUENCE = require('./drip-defaults.json'); + +// ---- storage ---- +const J = { + db: null, + FILE: () => path.join(DATA_DIR, 'drips.json'), + load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = {}; } }, + save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }, + async enqueue(rec) { if (!this.db) this.load(); if (this.db[rec.email]) return false; this.db[rec.email] = rec; this.save(); return true; }, + async due(now, limit) { if (!this.db) this.load(); return Object.values(this.db).filter(r => !r.stopped && r.nextAt <= now).sort((a, b) => a.nextAt - b.nextAt).slice(0, limit); }, + async update(email, fields) { if (!this.db) this.load(); if (this.db[email]) { Object.assign(this.db[email], fields); this.save(); } }, + async stats() { if (!this.db) this.load(); const v = Object.values(this.db); return { active: v.filter(r => !r.stopped).length, unsubscribed: v.filter(r => r.stopped === 2).length, done: v.filter(r => r.stopped === 1).length, total: v.length }; }, + async get(email) { if (!this.db) this.load(); return this.db[email] || null; } +}; +const rowR = r => ({ email: r.email, step: r.step, nextAt: Number(r.next_at), started: Number(r.started), stopped: r.stopped, ref: r.ref, angle: r.angle }); +const D = { + async enqueue(rec) { + try { + await db.q('INSERT INTO drips (email,step,next_at,started,stopped,ref,angle) VALUES (?,?,?,?,0,?,?)', + [rec.email, rec.step, rec.nextAt, rec.started, rec.ref || null, rec.angle || null]); + return true; + } catch (e) { if (e.code === 'ER_DUP_ENTRY') return false; throw e; } + }, + async due(now, limit) { return (await db.q('SELECT * FROM drips WHERE stopped=0 AND next_at<=? ORDER BY next_at LIMIT ?', [now, limit])).map(rowR); }, + async update(email, fields) { + const sets = [], vals = []; + if ('step' in fields) { sets.push('step=?'); vals.push(fields.step); } + if ('nextAt' in fields) { sets.push('next_at=?'); vals.push(fields.nextAt); } + if ('stopped' in fields) { sets.push('stopped=?'); vals.push(fields.stopped); } + if (!sets.length) return; + vals.push(email); + await db.q('UPDATE drips SET ' + sets.join(',') + ' WHERE email=?', vals); + }, + async stats() { + const r = await db.q('SELECT SUM(stopped=0) active, SUM(stopped=2) unsubscribed, SUM(stopped=1) done, COUNT(*) total FROM drips'); + return { active: Number(r[0].active || 0), unsubscribed: Number(r[0].unsubscribed || 0), done: Number(r[0].done || 0), total: Number(r[0].total || 0) }; + }, + async get(email) { const r = await db.q('SELECT * FROM drips WHERE email=?', [email]); return r.length ? rowR(r[0]) : null; } +}; +const impl = () => db.enabled() ? D : J; + +// ---- sequence config ---- +function seqFile() { return path.join(DATA_DIR, 'drip.json'); } +function sequence() { + try { + const saved = JSON.parse(fs.readFileSync(seqFile(), 'utf8')); + if (Array.isArray(saved) && saved.length) return saved.map(normStep).filter(Boolean); + } catch (e) {} + return DEFAULT_SEQUENCE; +} +function normStep(s) { + if (!s || typeof s !== 'object') return null; + const hours = Number(s.hours); + const subject = String(s.subject || '').trim().slice(0, 150); + const body = String(s.body || '').trim().slice(0, 8000); + if (!(hours >= 1) || !subject || !body) return null; + return { hours, subject, body }; +} +function setSequence(arr) { + if (!Array.isArray(arr)) return { error: 'Send a list of steps.' }; + const steps = arr.map(normStep); + if (steps.some(s => !s)) return { error: 'Every step needs hours (>= 1), a subject and a body.' }; + if (steps.length > 20) return { error: 'Keep it to 20 steps or fewer.' }; + for (let i = 1; i < steps.length; i++) if (steps[i].hours <= steps[i - 1].hours) return { error: 'Steps must be in increasing hours.' }; + fs.writeFileSync(seqFile(), JSON.stringify(steps, null, 2)); + return { ok: true, sequence: steps }; +} +function resetSequence() { try { fs.unlinkSync(seqFile()); } catch (e) {} return { ok: true, sequence: DEFAULT_SEQUENCE }; } + +// ---- unsubscribe signing ---- +function secret() { + const f = path.join(DATA_DIR, 'drip.secret'); + try { return fs.readFileSync(f, 'utf8').trim(); } catch (e) {} + const s = crypto.randomBytes(24).toString('hex'); + try { fs.writeFileSync(f, s, { mode: 0o600 }); } catch (e) {} + return s; +} +function token(email) { return crypto.createHmac('sha256', secret()).update(String(email).toLowerCase()).digest('hex').slice(0, 32); } +function unsubUrl(email) { return SITE + '/unsubscribe?e=' + encodeURIComponent(email) + '&t=' + token(email); } +async function unsubscribe(email, t) { + const e = String(email || '').trim().toLowerCase(); + if (!e || !t || t !== token(e)) return { error: 'That link is not valid.' }; + const cur = await impl().get(e); + if (!cur) { try { await enqueue(e, '', ''); await impl().update(e, { stopped: 2 }); } catch (err) {} return { ok: true }; } // member without a drip row: record the opt-out anyway (update emails honour it) + await impl().update(e, { stopped: 2 }); + return { ok: true }; +} +async function isUnsubscribed(email) { const cur = await impl().get(String(email || '').toLowerCase()); return !!(cur && cur.stopped === 2); } + +// ---- rendering ---- +async function vars(email) { + const a = accounts ? await accounts.byEmail(email) : null; + const tok = a ? (a.username || a.code || (a.memberId ? String(a.memberId) : '')) : ''; + let sponsor = 'your sponsor'; + try { + const sp = accounts && await accounts.sponsorOf(email); + if (sp) sponsor = sp.username ? '@' + sp.username : (sp.memberId ? 'member #' + sp.memberId : 'your sponsor'); + } catch (e) {} + // paid = has bought at least one package (on-chain credits minted); used by {{paid:a|b}} + let paid = false; + try { if (a && a.memberId && chain) paid = (await chain.creditBalance(a.memberId, 0)) > 0; } catch (e) {} + return { + link: tok ? SITE + '/join/' + tok : SITE + '/my', + sponsor, site: SITE, email, paid, + footer: 'You are getting these follow-ups because you created a free LinkSpin account. Stop them here: ' + unsubUrl(email) + + '\n\nLinkSpin · Advertising, not investing. No income is guaranteed; results depend on your effort. Crypto carries risk of loss.' + }; +} +// simple {{key}} placeholders resolve first (they may sit inside a branch), +// then {{paid:words if they bought|words if not}} picks the branch +function render(text, v) { + return String(text) + .replace(/\{\{(\w+)\}\}/g, (m, k) => (k in v && typeof v[k] !== 'boolean' ? v[k] : m)) + .replace(/\{\{paid:([\s\S]*?)\|([\s\S]*?)\}\}/g, (m, yes, no) => (v.paid ? yes : no)); +} + +// ---- lifecycle ---- +function init(opts) { + DATA_DIR = opts.dataDir; mailer = opts.mailer; accounts = opts.accounts; chain = opts.chain || null; + if (opts.site) SITE = opts.site; +} +// queue a fresh account; step 0 of the sequence is due `hours` after creation +async function enqueue(email, ref, angle) { + const e = String(email || '').trim().toLowerCase(); + if (!e) return false; + const seq = sequence(); + const now = Date.now(); + return impl().enqueue({ email: e, step: 0, nextAt: now + seq[0].hours * 3600000, started: now, stopped: 0, + ref: String(ref || '').slice(0, 40) || null, angle: String(angle || '').slice(0, 20) || null }); +} +async function sendStep(email, stepIdx, to) { + const seq = sequence(); + const s = seq[stepIdx]; + if (!s) return { error: 'No such step.' }; + const v = await vars(email); + await mailer.send(to || email, render(s.subject, v), render(s.body, v)); + return { ok: true }; +} +let ticking = false; +async function tick() { + if (ticking || !mailer || !mailer.hasKey()) return 0; + ticking = true; + let sent = 0; + try { + const seq = sequence(); + const now = Date.now(); + const rows = await impl().due(now, 50); + for (const r of rows) { + try { + if (r.step >= seq.length) { await impl().update(r.email, { stopped: 1 }); continue; } + await sendStep(r.email, r.step); + sent += 1; + const next = r.step + 1; + if (next >= seq.length) await impl().update(r.email, { step: next, stopped: 1 }); + else await impl().update(r.email, { step: next, nextAt: Math.max(now + 60000, r.started + seq[next].hours * 3600000) }); + } catch (e) { + console.error('drip send', r.email, e.message); + await impl().update(r.email, { nextAt: now + 6 * 3600000 }); // retry later, do not spin + } + } + } finally { ticking = false; } + return sent; +} +async function stats() { return impl().stats(); } + +module.exports = { init, enqueue, tick, stats, sequence, setSequence, resetSequence, sendStep, unsubscribe, unsubUrl, isUnsubscribed, DEFAULT_SEQUENCE }; diff --git a/geo.js b/geo.js new file mode 100644 index 0000000..0cbe9a2 --- /dev/null +++ b/geo.js @@ -0,0 +1,120 @@ +'use strict'; +// Country lookup for ad targeting, zero dependencies. Data: DB-IP "IP to Country +// Lite" (CC BY 4.0, attribution "IP Geolocation by DB-IP" is shown on the site). +// The server downloads the current month's CSV once, keeps it in DATA_DIR/geo/, +// loads it into sorted range tables (IPv4 as uint32, IPv6 as BigInt) and answers +// countryOf(ip) by binary search. Refreshes monthly. +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const zlib = require('zlib'); + +let DATA_DIR = '.'; +let v4 = { start: [], end: [], cc: [] }, v6 = { start: [], end: [], cc: [] }; +let loaded = false, loadedFile = ''; + +// Tier lists (affiliate-marketing convention). Editable in Admin > Settings as +// comma-separated ISO codes; tier 3 = everything else. +const DEFAULT_TIER1 = 'US,CA,GB,AU,NZ,IE,DE,FR,NL,SE,NO,DK,FI,CH,AT,BE'; +const DEFAULT_TIER2 = 'IT,ES,PT,PL,CZ,HU,GR,RO,SK,SI,HR,BG,EE,LV,LT,LU,IS,MT,CY,JP,KR,SG,HK,TW,IL,AE,SA,QA,KW,BH,OM,ZA,BR,MX,AR,CL,CO,UY,CR,PA,MY,TH,TR,RU,UA,KZ'; + +function ip4(s) { const p = s.split('.'); if (p.length !== 4) return null; let n = 0; for (const x of p) { const v = Number(x); if (!/^\d{1,3}$/.test(x) || v > 255) return null; n = n * 256 + v; } return n; } +function ip6(s) { + try { + let [head, tail] = s.split('::'); + const h = head ? head.split(':') : [], t = tail ? tail.split(':') : []; + if (s.includes('::')) { while (h.length + t.length < 8) h.push('0'); } + const parts = h.concat(t); if (parts.length !== 8) return null; + let n = 0n; for (const p of parts) { if (!/^[0-9a-f]{0,4}$/i.test(p)) return null; n = (n << 16n) + BigInt(parseInt(p || '0', 16)); } + return n; + } catch (e) { return null; } +} +function find(tbl, x) { + let lo = 0, hi = tbl.start.length - 1; + while (lo <= hi) { const mid = (lo + hi) >> 1; if (tbl.start[mid] <= x) { if (x <= tbl.end[mid]) return tbl.cc[mid]; lo = mid + 1; } else hi = mid - 1; } + return null; +} +// ISO-2 country code or null (private ranges, unknown, no data yet) +function countryOf(ip) { + if (!loaded || !ip) return null; + let s = String(ip).trim().replace(/^::ffff:/i, ''); + let cc = null; + if (s.includes('.')) { const n = ip4(s); cc = n == null ? null : find(v4, n); } + else { const n = ip6(s); cc = n == null ? null : find(v6, n); } + return cc && cc !== 'ZZ' ? cc : null; // ZZ = private/reserved: unknown +} + +function tierLists(cfg) { + const parse = (v, def) => new Set(String(v || def).toUpperCase().split(/[\s,]+/).filter(Boolean)); + return { t1: parse(cfg && cfg.geoTier1, DEFAULT_TIER1), t2: parse(cfg && cfg.geoTier2, DEFAULT_TIER2) }; +} +// '1' | '2' | '3' | null (unknown country never matches a restricted campaign) +function tierOf(cc, cfg) { + if (!cc) return null; + const { t1, t2 } = tierLists(cfg); + return t1.has(cc) ? '1' : t2.has(cc) ? '2' : '3'; +} + +// ── data file: DATA_DIR/geo/dbip-country-lite-YYYY-MM.csv.gz ── +function monthKey(d) { d = d || new Date(); return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0'); } +function fileFor(mk) { return path.join(DATA_DIR, 'geo', 'dbip-country-lite-' + mk + '.csv.gz'); } +function download(mk) { + return new Promise((resolve, reject) => { + fs.mkdirSync(path.join(DATA_DIR, 'geo'), { recursive: true }); + const tmp = fileFor(mk) + '.part'; + const req = https.get('https://download.db-ip.com/free/dbip-country-lite-' + mk + '.csv.gz', { headers: { 'User-Agent': 'LinkSpin/1.0 (+https://linkspin-test.saasy.top)' }, timeout: 60000 }, res => { + if (res.statusCode !== 200) { res.resume(); return reject(new Error('HTTP ' + res.statusCode)); } + const out = fs.createWriteStream(tmp); + res.pipe(out); out.on('finish', () => { fs.renameSync(tmp, fileFor(mk)); resolve(fileFor(mk)); }); out.on('error', reject); + }); + req.on('error', reject); req.on('timeout', () => { req.destroy(new Error('timeout')); }); + }); +} +function loadFile(file) { + const raw = zlib.gunzipSync(fs.readFileSync(file)).toString('utf8'); + const a4 = { start: [], end: [], cc: [] }, a6 = { start: [], end: [], cc: [] }; + for (const line of raw.split('\n')) { + if (!line) continue; + const c = line.indexOf(','), c2 = line.indexOf(',', c + 1); + if (c < 0 || c2 < 0) continue; + const s = line.slice(0, c), e = line.slice(c + 1, c2), cc = line.slice(c2 + 1).trim().toUpperCase(); + if (cc.length !== 2) continue; + if (s.includes('.')) { const a = ip4(s), b = ip4(e); if (a != null && b != null) { a4.start.push(a); a4.end.push(b); a4.cc.push(cc); } } + else { const a = ip6(s), b = ip6(e); if (a != null && b != null) { a6.start.push(a); a6.end.push(b); a6.cc.push(cc); } } + } + // DB-IP ships sorted; guard anyway + const order = t => { const idx = t.start.map((_, i) => i).sort((i, j) => (t.start[i] < t.start[j] ? -1 : t.start[i] > t.start[j] ? 1 : 0)); return { start: idx.map(i => t.start[i]), end: idx.map(i => t.end[i]), cc: idx.map(i => t.cc[i]) }; }; + v4 = order(a4); v6 = order(a6); loaded = true; loadedFile = file; + return { v4: v4.start.length, v6: v6.start.length }; +} +// load the newest local file now; fetch this month's if missing (async, non-blocking) +async function init(opts) { + DATA_DIR = opts.dataDir; + try { + const dir = path.join(DATA_DIR, 'geo'); + const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => /^dbip-country-lite-\d{4}-\d{2}\.csv\.gz$/.test(f)).sort() : []; + if (files.length) { const n = loadFile(path.join(dir, files[files.length - 1])); console.log('geo: loaded', files[files.length - 1], n.v4, 'v4 +', n.v6, 'v6 ranges'); } + } catch (e) { console.error('geo: load failed', e.message); } + refresh().catch(e => console.error('geo: refresh failed', e.message)); +} +// this month's file: download if missing, then (re)load it; safe to call daily +async function refresh() { + const mk = monthKey(); + const f = fileFor(mk); + if (!fs.existsSync(f)) { + try { await download(mk); } catch (e) { + // early in the month DB-IP may not have published yet: fall back to last month + const d = new Date(); d.setUTCMonth(d.getUTCMonth() - 1); const prev = monthKey(d); + if (!fs.existsSync(fileFor(prev))) await download(prev); + } + } + const dir = path.join(DATA_DIR, 'geo'); + const files = fs.readdirSync(dir).filter(x => /^dbip-country-lite-\d{4}-\d{2}\.csv\.gz$/.test(x)).sort(); + const newest = path.join(dir, files[files.length - 1]); + if (newest !== loadedFile) { const n = loadFile(newest); console.log('geo: loaded', files[files.length - 1], n.v4, 'v4 +', n.v6, 'v6 ranges'); } + for (const old of files.slice(0, -2)) { try { fs.unlinkSync(path.join(dir, old)); } catch (e) {} } + return { file: files[files.length - 1], ranges: v4.start.length + v6.start.length }; +} +function status() { return { loaded, file: path.basename(loadedFile || ''), v4: v4.start.length, v6: v6.start.length }; } + +module.exports = { init, refresh, countryOf, tierOf, tierLists, status, DEFAULT_TIER1, DEFAULT_TIER2 }; diff --git a/leaderboard.js b/leaderboard.js new file mode 100644 index 0000000..6298df0 --- /dev/null +++ b/leaderboard.js @@ -0,0 +1,146 @@ +// Leaderboard + weekly/monthly referral contest (Marty, 2026-09-14). +// Sales credit = tier-1 payouts on-chain: the direct sponsor of every package sold. A member's own linked +// positions and second accounts do not count for them (only sales to other people's accounts). Periods run +// on Central time: week = Monday 00:00 to Sunday 23:59, month = calendar month. Winners are recorded at +// rollover (checked hourly), announced to Telegram, and credit prizes are granted automatically. +const fs = require('fs'); +const path = require('path'); +let R = null; // { chain, accounts, ads, dataDir, siteConfig, notify(text), pushFeed(ev) } +const TZ = 'America/Chicago'; +let cache = { at: 0, rows: null }; +const FILE = () => path.join(R.dataDir, 'leaderboard-winners.json'); +function init(refs) { R = refs; } + +// Central-time helpers (no tz lib): shift by the zone offset at that instant +function ctParts(ts) { + const s = new Date(ts).toLocaleString('en-US', { timeZone: TZ, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', weekday: 'short' }); + const m = /(\w{3}), (\d{2})\/(\d{2})\/(\d{4}), (\d{2}):(\d{2})/.exec(s); + return { wd: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(m[1]), y: +m[4], mo: +m[2], d: +m[3], h: +m[5] % 24, mi: +m[6] }; +} +function ctMidnight(ts) { // the instant of 00:00 Central on the Central date of ts + const p = ctParts(ts); const guess = Date.UTC(p.y, p.mo - 1, p.d, 5, 0, 0); // CDT = UTC-5; CST = UTC-6 + const q = ctParts(guess); return (q.h === 0 && q.d === p.d) ? guess : guess + 3600000; +} +function weekStart(ts) { const p = ctParts(ts); const mid = ctMidnight(ts); const back = (p.wd + 6) % 7; return mid - back * 86400000; } +function monthStart(ts) { const p = ctParts(ts); return ctMidnight(Date.UTC(p.y, p.mo - 1, 1, 12)); } +function periodBounds(period, ts) { + const now = ts || Date.now(); + if (period === 'week') return { start: weekStart(now), end: now, label: 'This week' }; + if (period === 'lastweek') { const s = weekStart(now); return { start: weekStart(s - 1), end: s, label: 'Last week' }; } + if (period === 'month') return { start: monthStart(now), end: now, label: 'This month' }; + if (period === 'lastmonth') { const s = monthStart(now); return { start: monthStart(s - 1), end: s, label: 'Last month' }; } + return { start: 0, end: now, label: 'All time' }; +} + +// member id -> account email (main ids + linked positions), refreshed every minute +let idMap = { at: 0, map: {}, names: {} }; +async function memberMap() { + if (Date.now() - idMap.at < 60000) return idMap; + const map = {}, names = {}; + const list = await R.accounts.listAll(5000); + for (const a of list) { + if (a.memberId) { map[a.memberId] = a.email; names[a.email] = a.username ? '@' + a.username : 'member #' + a.memberId; } + else names[a.email] = a.username ? '@' + a.username : a.email.replace(/@.*/, '') + '@'; + try { for (const p of await R.accounts.positions(a.email)) if (p.memberId) map[p.memberId] = a.email; } catch (e) {} + } + idMap = { at: Date.now(), map, names }; return idMap; +} + +const computeCache = {}; +async function compute(period) { + const c = computeCache[period]; if (c && Date.now() - c.at < 30000) return c.val; + const val = await computeRaw(period); computeCache[period] = { at: Date.now(), val }; return val; +} +async function computeRaw(period) { + const { start, end, label } = periodBounds(period); + const { map, names } = await memberMap(); + const price = {}; // tx -> cents + for (const e of R.chain.recentEvents(1e9)) if (e.type === 'Purchase') price[e.tx + ':' + e.buyerId] = Number(e.priceCents || 0); + const rows = {}; + for (const e of R.chain.recentEvents(1e9)) { + if (e.type !== 'TierPaid' || e.tier !== 1 || e.ts < start || e.ts >= end) continue; + const sponsor = map[e.recipientId], buyer = map[e.buyerId]; + if (!sponsor) continue; + if (buyer && buyer === sponsor) continue; // own positions never count + const r = rows[sponsor] = rows[sponsor] || { email: sponsor, name: names[sponsor], sales: 0, cents: 0, pol: 0n, buyers: new Set() }; + r.sales += 1; r.cents += price[e.tx + ':' + e.buyerId] || 0; r.pol += BigInt(e.amountWei || 0); if (buyer) r.buyers.add(buyer); + } + // sign-ups sponsored in the period (site-side), for the secondary column + const joins = {}; + for (const a of await R.accounts.listAll(5000)) { + if (!a.sponsorRef || a.created < start || a.created >= end) continue; + let s = null; try { s = await R.accounts.sponsorOf(a.email); } catch (e) {} + if (s && s.email !== a.email) joins[s.email] = (joins[s.email] || 0) + 1; + } + for (const [em, n] of Object.entries(joins)) { const r = rows[em] = rows[em] || { email: em, name: names[em] || em, sales: 0, cents: 0, pol: 0n, buyers: new Set() }; r.joins = n; } + // the admin's own account (company placements, tank arrivals) is not a contestant + if (R.adminEmail && rows[R.adminEmail]) delete rows[R.adminEmail]; + const out = Object.values(rows).map(r => ({ email: r.email, name: r.name, sales: r.sales, usd: r.cents / 100, pol: Number(r.pol / 10n ** 14n) / 10000, buyers: r.buyers.size, joins: r.joins || 0 })) + .sort((a, b) => b.usd - a.usd || b.sales - a.sales || b.joins - a.joins); + out.forEach((r, i) => { r.rank = i + 1; }); + return { period, label, start, end, rows: out }; +} +async function view(period, meEmail) { + const key = period || 'week'; + const r = await compute(key); + const sc = R.siteConfig(); + const prize = prizeText(key.includes('month') ? 'month' : 'week', sc); + const me = meEmail ? r.rows.find(x => x.email === meEmail) : null; + return { period: r.period, label: r.label, start: r.start, end: r.end, prize: prize || '', top: r.rows.slice(0, 10).map(pub), me: me ? pub(me) : null, count: r.rows.length, + winners: winners().slice(0, 6) }; +} +const pub = r => ({ rank: r.rank, name: r.name, sales: r.sales, usd: r.usd, buyers: r.buyers, joins: r.joins }); +// "1000,500,250" -> [1000, 500, 250]: credits for 1st, 2nd, 3rd... (Marty: award the top X positions, 2026-09-14) +const ladder = v => String(v || '').split(',').map(x => Math.round(Number(x)) || 0).filter(n => n > 0); +const ORD = ['1st', '2nd', '3rd', '4th', '5th', '6th', '7th', '8th', '9th', '10th']; +const ladderText = l => l.length ? l.map((n, i) => ORD[i] + ' ' + n.toLocaleString()).join(' · ') + ' credits' : ''; +function prizeText(kind, sc) { const t = kind === 'week' ? sc.leaderboardWeeklyPrize : sc.leaderboardMonthlyPrize; if (t) return t; return ladderText(ladder(kind === 'week' ? sc.leaderboardWeeklyCredits : sc.leaderboardMonthlyCredits)); } +function winners() { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { return []; } } + +// rollover: once a completed week/month has no winner recorded, record it, grant credits, announce +async function rolloverTick() { + const now = Date.now(); const sc = R.siteConfig(); const w = winners(); let changed = false; + for (const kind of ['week', 'month']) { + const cur = kind === 'week' ? weekStart(now) : monthStart(now); + const prevStart = kind === 'week' ? weekStart(cur - 1) : monthStart(cur - 1); + if (prevStart < Date.parse('2026-09-08T05:00:00Z')) continue; // contest starts with the week of Sep 8 + if (w.find(x => x.kind === kind && x.start === prevStart)) continue; + const r = await compute(kind === 'week' ? 'lastweek' : 'lastmonth'); + const lad = ladder(kind === 'week' ? sc.leaderboardWeeklyCredits : sc.leaderboardMonthlyCredits); + const top = r.rows.filter(x => x.sales > 0).slice(0, Math.max(3, lad.length)); + const prize = prizeText(kind, sc); + const rec = { kind, start: prevStart, end: cur, prize: prize || '', ladder: lad, top: top.map(pub), granted: [], at: now }; + w.unshift(rec); changed = true; + for (let i = 0; i < lad.length && i < top.length; i++) { try { await R.ads.addEarned(top[i].email, lad[i]); rec.granted.push({ rank: i + 1, name: top[i].name, credits: lad[i] }); } catch (e) {} } + if (top[0]) { + const when = new Date(prevStart).toLocaleDateString('en-US', { timeZone: TZ, month: 'short', day: 'numeric' }); + const line = '\u{1F3C6} LinkSpin · ' + (kind === 'week' ? 'Weekly' : 'Monthly') + ' referral contest (from ' + when + '): ' + top[0].name + ' wins with ' + top[0].sales + ' package' + (top[0].sales === 1 ? '' : 's') + ' sold ($' + top[0].usd + ')' + (top[1] ? ' · 2nd ' + top[1].name + ' ($' + top[1].usd + ')' : '') + (top[2] ? ' · 3rd ' + top[2].name + ' ($' + top[2].usd + ')' : '') + (rec.granted.length ? '\nCredits awarded: ' + rec.granted.map(g => g.name + ' +' + g.credits.toLocaleString()).join(', ') : (prize ? '\nPrize: ' + prize : '')) + '\nlinkspin-test.saasy.top/leaderboard'; + try { await R.notify(line); } catch (e) {} + try { R.pushFeed({ type: 'Contest', kind, winner: top[0].name, ts: now }); } catch (e) {} + } + } + if (changed) fs.writeFileSync(FILE(), JSON.stringify(w.slice(0, 60), null, 1)); +} + +const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); +async function renderPage() { + const week = await view('week'), month = await view('month'), all = await view('all'); + const sc = R.siteConfig(); + const table = v => '' + + (v.top.length ? v.top.map(r => '').join('') : '') + '
#MemberPackagesSalesBuyersNew members
' + r.rank + '' + esc(r.name) + '' + r.sales + '$' + r.usd.toLocaleString() + '' + r.buyers + '' + r.joins + '
No sales yet in this period.
'; + const fmtD = ts => new Date(ts).toLocaleDateString('en-US', { timeZone: TZ, month: 'short', day: 'numeric' }); + const desc = 'Who is selling the most ad packages on LinkSpin this week and this month. Weekly and monthly referral contest standings, read from the chain.'; + let h = 'Leaderboard | LinkSpin' + + '' + + '
' + + '

Referral contest

Leaderboard: who is selling.

Ranked by ad packages sold to other people (your own positions never count, and the company account is not a contestant). Read from the chain, updated live. Weeks run Monday to Sunday, Central time.

'; + h += '

' + week.label + ' ' + fmtD(week.start) + ' to Sunday

' + (week.prize ? '
Weekly prizes: ' + esc(week.prize) + '
' : '') + table(week); + h += '

' + month.label + '

' + (month.prize ? '
Monthly prizes: ' + esc(month.prize) + '
' : '') + table(month); + h += '

All time

' + table(all); + const w = winners(); + if (w.length) h += '

Past winners

' + w.slice(0, 12).map(x => '
' + (x.kind === 'week' ? 'Week of ' : 'Month of ') + fmtD(x.start) + ': ' + (x.top[0] ? esc(x.top[0].name) + ' (' + x.top[0].sales + ' sold, $' + x.top[0].usd + ')' : 'no sales') + (x.granted && x.granted.length ? ' · awarded: ' + x.granted.map(g => esc(g.name) + ' +' + g.credits.toLocaleString()).join(', ') : (x.prize ? ' · prize ' + esc(x.prize) : '')) + '
').join(''); + h += '

Sales are $ of packages bought by members you directly sponsor. Prizes are advertising credits or packages, never cash. No income is guaranteed.

'; + h += '
'; + return h; +} +module.exports = { init, view, compute, renderPage, rolloverTick, winners, periodBounds }; diff --git a/legacy.js b/legacy.js new file mode 100644 index 0000000..787e0f6 --- /dev/null +++ b/legacy.js @@ -0,0 +1,33 @@ +// Legacy bridge: former Faucet Wave / Tier One Ads members (EvolutionScript sites Marty closed) +// arrive via /from/; if their email is on the private legacy list (DATA_DIR/legacy.json, +// email -> { b: 'faucetwave'|'tier1ads'|'both', s: 'a' (advertiser) | 'e' (earner) }) they get a +// one-time welcome-back credit grant at account creation. Grants are recorded in +// DATA_DIR/legacy-grants.json so a person is only ever credited once. +const fs = require('fs'); +const path = require('path'); +let DATA_DIR = null, list = null, grants = null; + +function init(opts) { DATA_DIR = opts.dataDir; list = null; grants = null; } +function load() { + if (list === null) { try { list = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'legacy.json'), 'utf8')); } catch (e) { list = {}; } } + if (grants === null) { try { grants = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'legacy-grants.json'), 'utf8')); } catch (e) { grants = {}; } } +} +function reload() { list = null; grants = null; load(); } +function lookup(email) { load(); return list[String(email || '').trim().toLowerCase()] || null; } +// credits for this email, or null when not listed / already granted. Records the grant. +function grant(email, cfg) { + load(); + const e = String(email || '').trim().toLowerCase(); + const rec = list[e]; + if (!rec || grants[e]) return null; + const credits = rec.s === 'a' ? (Number(cfg.legacyCreditsAdvertiser) || 500) : (Number(cfg.legacyCreditsEarner) || 150); + grants[e] = { credits, seg: rec.s, brand: rec.b, at: Date.now() }; + fs.writeFileSync(path.join(DATA_DIR, 'legacy-grants.json'), JSON.stringify(grants)); + return { credits, seg: rec.s, brand: rec.b }; +} +function stats() { + load(); + const g = Object.values(grants); + return { listed: Object.keys(list).length, granted: g.length, credits: g.reduce((a, x) => a + (x.credits || 0), 0) }; +} +module.exports = { init, reload, lookup, grant, stats }; diff --git a/mailer.js b/mailer.js new file mode 100644 index 0000000..3df44c8 --- /dev/null +++ b/mailer.js @@ -0,0 +1,47 @@ +// Outbound mail via SendGrid v3 (domain-authenticated linkspin-test.saasy.top). +// Key sources: SENDGRID_KEY env, else DATA_DIR/sendgrid.key in the volume. +// No key = email sign-in stays feature-flagged off in production. +const fs = require('fs'); +const path = require('path'); +const https = require('https'); + +let DATA_DIR = null; +const FROM = { email: 'no-reply@linkspin-test.saasy.top', name: 'LinkSpin' }; + +function init(opts) { DATA_DIR = opts.dataDir; } +function key() { + if (process.env.SENDGRID_KEY) return process.env.SENDGRID_KEY.trim(); + try { return fs.readFileSync(path.join(DATA_DIR, 'sendgrid.key'), 'utf8').trim(); } catch (e) { return ''; } +} +function hasKey() { return !!key(); } + +function send(to, subject, text) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: FROM, + subject, + content: [{ type: 'text/plain', value: text }] + }); + const req = https.request({ hostname: 'api.sendgrid.com', path: '/v3/mail/send', method: 'POST', + headers: { Authorization: 'Bearer ' + key(), 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) }, timeout: 15000 }, + res => { + let d = ''; + res.on('data', c => d += c); + res.on('end', () => res.statusCode < 300 ? resolve(true) : reject(new Error('sendgrid ' + res.statusCode + ': ' + d.slice(0, 200)))); + }); + req.on('error', reject); + req.on('timeout', () => req.destroy(new Error('sendgrid timeout'))); + req.end(body); + }); +} + +function sendCode(to, code) { + return send(to, code + ' is your LinkSpin sign-in code', + 'Your sign-in code is: ' + code + '\n\n' + + 'It works for 15 minutes. If you did not request it, ignore this email.\n\n' + + 'LinkSpin\nAdvertise and earn instantly. Locked in code, not promises.\nhttps://linkspin-test.saasy.top'); +} + +module.exports = { init, hasKey, send, sendCode }; diff --git a/messages.js b/messages.js new file mode 100644 index 0000000..67ebf04 --- /dev/null +++ b/messages.js @@ -0,0 +1,181 @@ +// 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=? AND kind='broadcast'", [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 && (i.kind || 'broadcast') === 'broadcast').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,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++, 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=? 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 && 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 +// last message from one member to another, any kind (0 if never) +async function lastFrom(fromEmail, toEmail) { + const f = String(fromEmail || '').toLowerCase(), t = String(toEmail || '').toLowerCase(); + if (db.enabled()) { const r = await db.q('SELECT MAX(sent) s FROM sponsor_messages WHERE from_email=? AND to_email=?', [f, t]); return Number((r[0] && r[0].s) || 0); } + if (!J.db) J.load(); + return J.db.items.filter(i => i.fromEmail === f && i.toEmail === t).reduce((m, i) => Math.max(m, i.sent || 0), 0); +} +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 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 && isBroadcast(i) && !i.readTs).length; +} +// 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 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 && 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) { + 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, lastFrom, lastBroadcastAt, deliver, inbox, unreadCount, newestUnread, markRead, + sendChat, thread, markChatRead, chatUnread, threadList }; diff --git a/nas.js b/nas.js new file mode 100644 index 0000000..94fa5ff --- /dev/null +++ b/nas.js @@ -0,0 +1,125 @@ +// NAS syndication — pushes IAP campaigns out to Network Ad Space (Marty's own +// EvolutionScript platform; no source, so its MySQL DB IS the API). Mirrors the +// CTB Rewards direct-write pattern: INSERT payments then sponsorads with +// approved:1 (bypasses NAS moderation), and reads `remaining` back to reconcile +// spend into IAP's unified credit pool. +// +// KNOWN NAS TRUTHS (measured across RM Circle + CTB, 1600+ ads): +// - sponsorads.remaining counts DOWN: served = assigned - remaining. +// - pid 1 = text, pid 2 = banner ONLY; banner size lives in width/height. +// - hits = clicks (not impressions). catid 5 = Cryptocurrencies. +// - NAS drifts its own counters upward post-insert → clamp served to assigned. +// +// FEATURE-FLAGGED: inert unless NAS_DB_HOST/USER/PASSWORD/NAME are all set. +// Nothing here runs (no connection, no writes) when the flag is off. +const crypto = require('crypto'); + +let pool = null; +function enabled() { + return !!(process.env.NAS_DB_HOST && process.env.NAS_DB_USER + && process.env.NAS_DB_PASSWORD && process.env.NAS_DB_NAME); +} +function nasPool() { + if (pool) return pool; + const mysql = require('mysql2/promise'); + pool = mysql.createPool({ + host: process.env.NAS_DB_HOST, + port: Number(process.env.NAS_DB_PORT || 3306), + user: process.env.NAS_DB_USER, + password: process.env.NAS_DB_PASSWORD, + database: process.env.NAS_DB_NAME, + charset: 'latin1', // EvolutionScript is latin1 + connectionLimit: 3, + connectTimeout: 10000 + }); + return pool; +} +async function q(sql, args) { const [r] = await nasPool().query(sql, args); return r; } + +const CATID_CRYPTO = Number(process.env.NAS_CATID || 5); +// how many NAS impressions one IAP credit buys, per format. IAP charges credits +// on its own surfaces at these same rates, so NAS delivery draws the same pool. +function impressionsPerCredit(type) { + return type === 'banner' ? 5 : type === 'text' ? 10 : type === 'video' ? 0 : 0; +} +function nasKind(type) { + // pid encodes text(1)/banner(2); adtype is a separate small int — 1 is the + // value the overwhelming majority of live NAS rows use for both formats. + const adtype = Number(process.env.NAS_ADTYPE || 1); + if (type === 'banner') return { pid: 2, adtype }; + if (type === 'text') return { pid: 1, adtype }; + return null; // only banner/text syndicate to NAS in v1 (login/solo/video are IAP-native) +} + +// push one IAP campaign into NAS. `c` is a pubC-shaped campaign. Returns +// { nasAdId, assigned } or { skipped } / throws on a real DB error. +async function pushCampaign(c, opts = {}) { + if (!enabled()) return { skipped: 'flag-off' }; + const kind = nasKind(c.type); + if (!kind) return { skipped: 'type' }; + const budgetLeft = c.budget - (c.spent || 0); + const window = c.dailyCap ? Math.min(c.dailyCap, budgetLeft) : budgetLeft; // capped campaigns start with one day's allowance + const assigned = Math.max(1, Math.floor(window * impressionsPerCredit(c.type))); + const token = 'iap_' + crypto.randomBytes(8).toString('hex'); // manage token = Username + const now = new Date(); + const days = Number(opts.days || 30); + const edate = new Date(now.getTime() + days * 86400000); + const payref = 'iap_campaign:' + c.id; + // payments first (MyISAM, no txn) — hand-rollback the row if sponsorads fails + const pay = await q( + 'INSERT INTO payments (Username, Amount, Currency_code, status, Date, pay_address) VALUES (?,?,?,1,?,?)', + [token, 0, 'IAP_CREDIT', now, payref]); + try { + const ad = await q( + `INSERT INTO sponsorads + (Username, Subject, Body, WebsiteURL, assigned, remaining, hits, approved, Date, adtype, + Name, Email, PaymentDetails, EDate, sp, width, height, BannerURL, pid, ref_by, catid) + VALUES (?,?,?,?,?,?,0,1,?,?,?,?,?,?,'',?,?,?,?,0,?)`, + // clicks route through our redirect so they count and carry the network site as the referrer + [token, c.title || null, c.type === 'text' ? (c.body || null) : null, 'https://linkspin-test.saasy.top/api/ads/click/' + c.id, + assigned, assigned, now, kind.adtype, + opts.name || 'LinkSpin member', opts.email || 'ads@linkspin-test.saasy.top', + 'LinkSpin campaign #' + c.id, edate, + c.width || '', c.height || '', c.type === 'banner' ? c.imageUrl : null, kind.pid, CATID_CRYPTO]); + return { nasAdId: ad.insertId, manageToken: token, assigned }; + } catch (e) { + try { await q('DELETE FROM payments WHERE ID=?', [pay.insertId]); } catch (e2) {} + throw e; + } +} + +// read served count for a syndicated ad (assigned - remaining, clamped ≥0 and +// ≤ assigned because NAS drifts counters upward post-insert). +async function readServed(nasAdId) { + if (!enabled()) return null; + const rows = await q('SELECT assigned, remaining, hits FROM sponsorads WHERE ID=?', [Number(nasAdId)]); + if (!rows.length) return null; + const assigned = Number(rows[0].assigned) || 0; + const remaining = Number(rows[0].remaining) || 0; + const served = Math.max(0, Math.min(assigned, assigned - remaining)); + return { assigned, remaining, served, clicks: Number(rows[0].hits) || 0 }; +} + +// stop a syndicated ad (budget spent, paused, or expired) — remaining:0 halts serving. +async function deactivate(nasAdId) { + if (!enabled()) return { skipped: 'flag-off' }; + await q('UPDATE sponsorads SET remaining=0, EDate=NOW() WHERE ID=?', [Number(nasAdId)]); + return { ok: true }; +} + +// top up a syndicated ad with more impressions (buy-more-views / reactivate). +// point an existing NAS ad at our click redirect (migration for rows pushed before 2026-09-10) +async function setClickUrl(nasAdId, campaignId) { + if (!enabled()) return { skipped: 'flag-off' }; + await q('UPDATE sponsorads SET WebsiteURL=? WHERE ID=?', ['https://linkspin-test.saasy.top/api/ads/click/' + Number(campaignId), Number(nasAdId)]); + return { ok: true }; +} +async function topUp(nasAdId, addImpressions, days) { + if (!enabled()) return { skipped: 'flag-off' }; + await q(`UPDATE sponsorads SET assigned=assigned+?, remaining=remaining+?, approved=1, + EDate=DATE_ADD(NOW(), INTERVAL ? DAY) WHERE ID=?`, + [Number(addImpressions), Number(addImpressions), Number(days || 30), Number(nasAdId)]); + return { ok: true }; +} + +module.exports = { setClickUrl, enabled, pushCampaign, readServed, deactivate, topUp, impressionsPerCredit, nasKind }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5c5f19c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,563 @@ +{ + "name": "linkspin-site", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "linkspin-site", + "version": "0.1.0", + "dependencies": { + "ethers": "^6.13.0", + "mysql2": "^3.11.0", + "qrcode": "^1.5.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/node": { + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", + "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/ethers": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", + "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.11.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.21.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/mysql2": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.3.tgz", + "integrity": "sha512-OKfWHkMAg9v06neq8FmSyhbxPQKABN9PAW5G9/bDTXzJBO5xXtkKL0V27vju7HQWk9UD4Od5BZsvCtBTB1CPEw==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d16b53e --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "linkspin-site", + "version": "0.1.0", + "private": true, + "description": "LinkSpin membership advertising site - immutable on-chain settlement, transparent ledger.", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js", + "qa": "bash qa/run.sh all", + "qa:public": "bash qa/run.sh public", + "qa:member": "bash qa/run.sh member", + "qa:earn": "bash qa/run.sh earn" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "mysql2": "^3.11.0", + "qrcode": "^1.5.4", + "ethers": "^6.13.0" + } +} \ No newline at end of file diff --git a/pipeline.js b/pipeline.js new file mode 100644 index 0000000..7b961c1 --- /dev/null +++ b/pipeline.js @@ -0,0 +1,132 @@ +// Pipeline (Marty, 2026-09-15): a follow-up board for sponsors. One screen where every prospect +// and every direct sits in a column by what they have actually done. Nobody drags a card: the +// site moves it when the event fires (wallet linked, payouts on, first buy, buyers counted). +// The sponsor adds what the site cannot know: a note, a follow-up date, an outcome tag. +// Built before launch, shown as "coming soon" until site setting pipelineMode = on +// (preview = only the admin account sees the live board, for testing and the training video). +// Dual-mode store like the other modules (MySQL when DATABASE_URL is set, JSON on the volume). +const fs = require('fs'); +const path = require('path'); +const db = require('./db'); + +let DATA_DIR = null, accounts = null, coach = null, chain = null; +// linked positions (Qualified Start) belong to the member and their buyers count toward badges, but the contract +// pays level 2 and 3 to the MAIN wallet only when that wallet's own buyerCount reaches 2 and 5. The card shows both +// numbers; the stage and the advice follow the main wallet, because that is what gets paid (Marty's audit, 2026-09-15) +const posCache = new Map(); +async function positionBuyers(email) { + let ids = []; try { ids = (await accounts.positions(email)).map(p => p.memberId).filter(Boolean); } catch (e) {} + let n = 0; + for (const id of ids) { + const c = posCache.get(id); let v = c && Date.now() - c.t < 60000 ? c.v : null; + if (v == null) { try { v = (await chain.member(id)).buyerCount || 0; } catch (e) { v = 0; } posCache.set(id, { t: Date.now(), v }); } + n += v; + } + return { count: n, positions: ids.length }; +} +const DAY = 86400000; + +// columns, in order. Prospects live in the first and last; directs land by coaching rung. +const STAGES = [ + { key: 'talking', label: 'Talking to', hint: 'People you have reached out to who have not joined yet.' }, + { key: 'joined', label: 'Joined', hint: 'Joined free. Next: link a wallet.' }, + { key: 'wallet', label: 'Wallet linked', hint: 'Next: switch on payouts, one free transaction.' }, + { key: 'payouts', label: 'Payouts on', hint: 'Next: a first package. $20 or more counts for you.' }, + { key: 'bought', label: 'Bought', hint: 'Your qualifying buyer. Next: their first person.' }, + { key: 'building', label: 'Building', hint: 'They have buyers of their own. Coach them to their two.' }, + { key: 'later', label: 'Later', hint: 'Parked: not now, no response, not interested.' } +]; +const TAGS = ['', 'hot', 'later', 'no response', 'not interested']; +const RUNG_STAGE = ['joined', 'wallet', 'payouts', 'bought', 'building', 'building', 'building']; +const PARKED = new Set(['later', 'no response', 'not interested']); + +// ---- stores: one row per (owner, person) ---- +const J = { + db: null, + FILE: () => path.join(DATA_DIR, 'pipeline.json'), + load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = {}; } }, + save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }, + async notes(owner) { if (!this.db) this.load(); return Object.values(this.db).filter(r => r.owner === owner); }, + async put(owner, person, f) { if (!this.db) this.load(); const k = owner + '|' + person; this.db[k] = Object.assign(this.db[k] || { owner, person }, f, { updated: Date.now() }); this.save(); return this.db[k]; } +}; +const rowR = r => ({ owner: r.owner_email, person: r.person, note: r.note || '', followUp: r.follow_up ? Number(r.follow_up) : null, tag: r.tag || '', updated: Number(r.updated) }); +const D = { + async notes(owner) { return (await db.q('SELECT * FROM pipeline_notes WHERE owner_email=?', [owner])).map(rowR); }, + async put(owner, person, f) { + await db.q('INSERT INTO pipeline_notes (owner_email,person,note,follow_up,tag,updated) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE note=VALUES(note), follow_up=VALUES(follow_up), tag=VALUES(tag), updated=VALUES(updated)', + [owner, person, f.note, f.followUp || null, f.tag || '', Date.now()]); + return rowR((await db.q('SELECT * FROM pipeline_notes WHERE owner_email=? AND person=?', [owner, person]))[0]); + } +}; +const impl = () => db.enabled() ? D : J; + +function init(opts) { DATA_DIR = opts.dataDir; accounts = opts.accounts; coach = opts.coach; chain = opts.chain; } + +// who may see the live board: mode on = everyone; preview = the admin account only +function visible(mode, email, adminEmail) { + if (mode === 'on') return true; + if (mode === 'preview') return !!(email && adminEmail && String(email).toLowerCase() === String(adminEmail).toLowerCase()); + return false; +} + +async function board(email) { + const owner = String(email || '').toLowerCase(); + const now = Date.now(); const eod = new Date(); eod.setHours(23, 59, 59, 999); + const notes = {}; for (const n of await impl().notes(owner)) notes[n.person] = n; + const cards = []; + // prospects: the sponsor's own list of people they have talked to + for (const p of await coach.prospects(owner)) { + const n = notes['p:' + p.id] || {}; + let stage = 'talking'; + if (p.status === 'not now') stage = 'later'; + if (p.status === 'joined') stage = 'joined'; + if (p.status === 'bought') stage = 'bought'; + if (PARKED.has(n.tag)) stage = 'later'; + cards.push({ key: 'p:' + p.id, kind: 'prospect', name: p.name, contact: p.contact || '', status: p.status, stage, + since: p.updated || p.created || now, lastSeen: 0, quietDays: Math.floor((now - (p.updated || p.created || now)) / DAY), + stalled: false, next: p.status === 'contacted' || p.status === 'interested' ? 'Send your link and ask for a yes' : 'Reach out once', + say: 'Hey ' + p.name + ', here is the link I mentioned. Free to join with an email, and I will walk you through the first three steps: {{link}}', + note: n.note || p.note || '', followUp: n.followUp || p.nextTs || null, tag: n.tag || '' }); + } + // directs: stage from what they have actually done (the coaching rung) + const levels = await accounts.downline(owner, 1); + for (const m of (levels[0] ? levels[0].members : [])) { + let c = null; try { c = await coach.describe(m, now); } catch (e) {} + const n = notes[m.email] || {}; + const pb = c && c.rung >= 3 ? await positionBuyers(m.email) : { count: 0, positions: 0 }; + const buyersMain = c ? c.buyerCount || 0 : 0, buyersAll = buyersMain + pb.count; + const rung = c ? c.rung : 0; + const RR = coach.RUNGS[rung]; + // when positions carry buyers the main wallet does not, say exactly what the contract needs + const gap = pb.count && rung >= 3 && rung < 6 ? ' The contract pays level ' + (buyersMain < 2 ? '2' : '3') + ' to their main wallet only when that wallet has ' + (buyersMain < 2 ? 2 : 5) + ' qualifying buyers of its own: ' + buyersMain + ' now, plus ' + pb.count + ' on linked positions that count for badges only.' : ''; + const stage = PARKED.has(n.tag) && rung < 3 ? 'later' : RUNG_STAGE[rung] || 'joined'; + cards.push({ key: m.email, kind: 'member', email: m.email, name: m.username ? '@' + m.username : (m.memberId ? 'member #' + m.memberId : 'member'), + memberId: m.memberId || 0, stage, rung, rungLabel: RR.label, since: m.created || now, + lastSeen: m.lastSeen || 0, quietDays: c ? c.quietDays : 0, stalled: !!(c && c.stalled && rung < 6), + buyers: buyersAll, buyersMain, buyersPositions: pb.count, positions: pb.positions, bought: !!(c && c.counted), + next: RR.next + gap, say: RR.say.replace('{{name}}', m.username || 'there'), + note: n.note || '', followUp: n.followUp || null, tag: n.tag || '' }); + } + const due = cards.filter(c => c.followUp && c.followUp <= eod.getTime()).sort((a, b) => a.followUp - b.followUp); + const columns = STAGES.map(s => ({ key: s.key, label: s.label, hint: s.hint, + cards: cards.filter(c => c.stage === s.key).sort((a, b) => (b.stalled - a.stalled) || (a.followUp || 9e15) - (b.followUp || 9e15) || b.since - a.since) })); + return { stages: STAGES, tags: TAGS, columns, due, counts: { total: cards.length, stalled: cards.filter(c => c.stalled).length, due: due.length } }; +} + +async function save(email, body) { + const owner = String(email || '').toLowerCase(); + const person = String(body.key || '').trim().toLowerCase().slice(0, 190); + if (!person) return { error: 'Pick who to update.' }; + // only people on the sponsor's own board + const ok = person.startsWith('p:') ? (await coach.prospects(owner)).some(p => 'p:' + p.id === person) + : await accounts.isDownlineOf(owner, person, 1); + if (!ok) return { error: 'Not on your board.' }; + const tag = TAGS.includes(String(body.tag || '').toLowerCase()) ? String(body.tag || '').toLowerCase() : ''; + let followUp = null; + if (body.followUp) { const t = /^\d{4}-\d{2}-\d{2}$/.test(body.followUp) ? Date.parse(body.followUp + 'T12:00:00') : Number(body.followUp); if (t && !isNaN(t)) followUp = t; } + const note = String(body.note || '').trim().slice(0, 1000); + const row = await impl().put(owner, person, { note, followUp, tag }); + return { ok: true, note: row }; +} + +module.exports = { init, STAGES, TAGS, visible, board, save }; diff --git a/promos.js b/promos.js new file mode 100644 index 0000000..bdea814 --- /dev/null +++ b/promos.js @@ -0,0 +1,74 @@ +// Partner promo codes (Marty, 2026-09-12): a reusable code per partner site owner that gives +// members who redeem it free ad credits (earned-grade) on top of whatever they already get. +// Redeemed automatically when someone joins through a link carrying ?promo=CODE, or typed into +// the dashboard. One redemption per code per account; every redemption is logged; optional +// cap (max uses) and expiry; codes can be switched off. Storage: MySQL promo_codes + +// promo_redemptions, or DATA_DIR/promos.json. +const fs = require('fs'); +const path = require('path'); +const db = require('./db'); +let DATA_DIR = null; +const norm = c => String(c || '').trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '').slice(0, 24); + +const J = { + db: { v: 1, codes: {}, redemptions: [] }, + FILE() { return path.join(DATA_DIR, 'promos.json'); }, + load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} }, + save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }, + async get(code) { return this.db.codes[code] || null; }, + async list() { return Object.values(this.db.codes).sort((a, b) => b.created - a.created); }, + async put(c) { this.db.codes[c.code] = Object.assign(this.db.codes[c.code] || {}, c); this.save(); return this.db.codes[c.code]; }, + async uses(code) { return this.db.redemptions.filter(r => r.code === code).length; }, + async redeemed(code, email) { return this.db.redemptions.some(r => r.code === code && r.email === email); }, + async addRedemption(r) { this.db.redemptions.push(r); this.save(); }, + async redemptions(code, n) { return this.db.redemptions.filter(r => !code || r.code === code).slice(-(n || 200)).reverse(); } +}; +const D = { + async get(code) { const r = await db.q('SELECT * FROM promo_codes WHERE code=?', [code]); return r[0] ? row(r[0]) : null; }, + async list() { return (await db.q('SELECT * FROM promo_codes ORDER BY created DESC')).map(row); }, + async put(c) { + await db.q('INSERT INTO promo_codes (code,credits,partner,note,max_uses,expires,active,created,funder) VALUES (?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE funder=VALUES(funder), credits=VALUES(credits), partner=VALUES(partner), note=VALUES(note), max_uses=VALUES(max_uses), expires=VALUES(expires), active=VALUES(active)', + [c.code, c.credits, c.partner || null, c.note || null, c.maxUses || 0, c.expires || 0, c.active ? 1 : 0, c.created || Date.now(), c.funder || null]); + return this.get(c.code); + }, + async uses(code) { const r = await db.q('SELECT COUNT(*) n FROM promo_redemptions WHERE code=?', [code]); return Number(r[0].n); }, + async redeemed(code, email) { const r = await db.q('SELECT 1 FROM promo_redemptions WHERE code=? AND email=?', [code, email]); return r.length > 0; }, + async addRedemption(r) { await db.q('INSERT IGNORE INTO promo_redemptions (code,email,credits,via,ts) VALUES (?,?,?,?,?)', [r.code, r.email, r.credits, r.via, r.ts]); }, + async redemptions(code, n) { const rows = code ? await db.q('SELECT * FROM promo_redemptions WHERE code=? ORDER BY ts DESC LIMIT ?', [code, Number(n) || 200]) : await db.q('SELECT * FROM promo_redemptions ORDER BY ts DESC LIMIT ?', [Number(n) || 200]); return rows.map(r => ({ code: r.code, email: r.email, credits: Number(r.credits), via: r.via, ts: Number(r.ts) })); } +}; +const row = r => ({ code: r.code, funder: r.funder || null, credits: Number(r.credits), partner: r.partner || '', note: r.note || '', maxUses: Number(r.max_uses) || 0, expires: Number(r.expires) || 0, active: !!Number(r.active), created: Number(r.created) }); +const impl = () => db.enabled() ? D : J; + +function init(opts) { DATA_DIR = opts.dataDir; if (!db.enabled()) J.load(); } +async function create(c) { + const code = norm(c.code); if (!code || code.length < 3) return { error: 'Code must be 3 to 24 letters or numbers.' }; + const credits = Math.floor(Number(c.credits)); if (!(credits > 0) || credits > 100000) return { error: 'Credits must be between 1 and 100,000.' }; + const cur = await impl().get(code); + const saved = await impl().put({ code, credits, funder: c.funder ? String(c.funder).toLowerCase() : (cur && cur.funder) || null, partner: String(c.partner || '').slice(0, 80), note: String(c.note || '').slice(0, 200), maxUses: Math.max(0, Math.floor(Number(c.maxUses) || 0)), expires: c.expires ? Number(new Date(c.expires)) || 0 : 0, active: c.active !== false && c.active !== 0 && c.active !== '0', created: cur ? cur.created : Date.now() }); + return { ok: true, code: saved }; +} +async function setActive(code, active) { const c = await impl().get(norm(code)); if (!c) return { error: 'No such code.' }; await impl().put(Object.assign(c, { active: !!active })); return { ok: true }; } +// why a code cannot be used right now, or null when it can (email optional: skips the per-account check) +async function check(code, email) { + const k = norm(code); if (!k) return { error: 'Enter a promo code.' }; + const c = await impl().get(k); if (!c || !c.active) return { error: 'That promo code is not valid.' }; + if (c.expires && Date.now() > c.expires) return { error: 'That promo code has expired.' }; + if (email && await impl().redeemed(k, email)) return { error: 'You already used that promo code.' }; + if (c.maxUses && (await impl().uses(k)) >= c.maxUses) return { error: 'That promo code has been fully redeemed.' }; + return null; +} +// redeem for an account; the caller adds the credits. via = 'link' | 'dashboard' +async function redeem(code, email, via) { + const k = norm(code); const e = String(email || '').toLowerCase(); + const bad = await check(k, e); if (bad) return bad; + const c = await impl().get(k); + await impl().addRedemption({ code: k, email: e, credits: c.credits, via: via || 'dashboard', ts: Date.now() }); + return { ok: true, credits: c.credits, code: k, partner: c.partner, funder: c.funder || null }; +} +async function adminView() { + const codes = await impl().list(); + for (const c of codes) c.uses = await impl().uses(c.code); + return { codes, recent: await impl().redemptions(null, 100) }; +} +async function byFunder(email) { return (await impl().list()).filter(c => c.funder === String(email || '').toLowerCase()); } +module.exports = { init, create, setActive, check, redeem, adminView, norm, byFunder }; diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..6e092c7 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,492 @@ + + + + + +Admin | LinkSpin + + + + + + + + +
+
+
+

LinkSpin

+

Admin sign in

+

Only the admin address can sign in here. A one-time code goes to that inbox.

+
+
+

+ + +

+ + +

+

Back to the member area

+
+
+
+ + + + + + + + diff --git a/public/assets/admin.js b/public/assets/admin.js new file mode 100644 index 0000000..800c882 --- /dev/null +++ b/public/assets/admin.js @@ -0,0 +1,843 @@ +// Admin portal: email-code sign-in (allowlisted to ADMIN_EMAIL on the server), +// house ads that cost nothing, every campaign, members, reports, settings. +(function () { + const $ = IAP.$; + const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + async function api(path, body, method) { + const opts = { method: method || (body === undefined ? 'GET' : 'POST'), headers: {} }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + const r = await (await fetch(path, opts)).json(); + if (r.error) throw new Error(r.error === 'auth' ? 'Session expired. Sign in again.' : r.error); + return r; + } + function busy(btn, fn) { + return async (...a) => { + if (btn.disabled) return; + btn.disabled = true; + try { await fn(...a); } catch (e) { IAP.status(e.message || 'Something went wrong.', 'bad'); } + finally { btn.disabled = false; } + }; + } + const when = ts => ts ? new Date(Number(ts)).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''; + let rates = {}, sizes = [], houseOwner = 'house@linkspin-test.saasy.top'; + + // ── sign-in ── + $('adSend').addEventListener('click', busy($('adSend'), async () => { + $('adErr').hidden = true; + const r = await api('/api/admin/auth/start', { email: $('adEmail').value }); + $('adCodeRow').hidden = false; $('adVerify').hidden = false; + if (r.devCode) $('adCode').value = r.devCode; + IAP.status(r.sent ? 'Code sent. Check your inbox.' : 'Dev mode: code filled in.', 'ok'); + $('adCode').focus(); + })); + $('adVerify').addEventListener('click', busy($('adVerify'), async () => { + $('adErr').hidden = true; + await api('/api/admin/auth/verify', { email: $('adEmail').value, code: $('adCode').value }); + await render(); + })); + $('adCode').addEventListener('keydown', e => { if (e.key === 'Enter') $('adVerify').click(); }); + $('adEmail').addEventListener('keydown', e => { if (e.key === 'Enter') ($('adVerify').hidden ? $('adSend') : $('adVerify')).click(); }); + $('adLogout').addEventListener('click', async e => { + e.preventDefault(); + try { await api('/api/admin/auth/logout', {}); } catch (err) {} + location.reload(); + }); + + // ── panes ── + const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', traffic: 'Traffic', blog: 'Blog', releases: 'Releases and roadmap', pnl: 'Profit and loss', settings: 'Settings' }; + const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, traffic: loadTraffic, blog: loadBlog, releases: loadReleases, pnl: loadPnl, settings: loadSettings }; + function setPane(name) { + if (!TITLES[name]) name = 'overview'; + document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; }); + document.querySelectorAll('.bo-menu [data-pane]').forEach(b => b.classList.toggle('on', b.dataset.pane === name)); + $('boTitle').textContent = TITLES[name]; + if (location.hash.slice(1) !== name) history.replaceState(null, '', '#' + name); + $('adminArea').classList.remove('side-open'); + loaders[name]().catch(e => IAP.status(e.message, 'bad')); + } + document.querySelectorAll('.bo-menu [data-pane]').forEach(b => b.addEventListener('click', () => setPane(b.dataset.pane))); + document.addEventListener('click', e => { const g = e.target.closest('[data-goto]'); if (g) setPane(g.dataset.goto); }); + window.addEventListener('hashchange', () => setPane(location.hash.slice(1))); + $('boBurger').addEventListener('click', () => $('adminArea').classList.toggle('side-open')); + + async function render() { + let me = { admin: false }; + try { me = await api('/api/admin/me'); } catch (e) {} + $('authArea').hidden = !!me.admin; + $('adminArea').hidden = !me.admin; + if (!me.admin) return; + $('adWho').textContent = me.email || 'admin'; + try { + const c = await IAP.getConfig(); + $('chainLine').textContent = c.chainName + (c.rehearsal ? ' · rehearsal' : ''); + } catch (e) {} + setPane(location.hash.slice(1) || 'overview'); + } + + // ── overview ── + async function loadOverview() { + const o = await api('/api/admin/overview'); + rates = o.rates || rates; + $('ovAccounts').textContent = (o.accounts || 0).toLocaleString(); + $('ovMembers').textContent = o.memberCount == null ? '?' : Number(o.memberCount).toLocaleString(); + $('ovActive').textContent = (o.byStatus && o.byStatus.active) || 0; + $('ovCampSub').textContent = o.campaigns + ' total · ' + o.house + ' house'; + $('ovReports').textContent = o.openReports || 0; + $('ovBurnSub').textContent = (o.pendingBurns || 0) + ' pending burns'; + $('repBadge').hidden = !o.openReports; $('repBadge').textContent = o.openReports || ''; + const f = o.followups || {}; + $('ovDrips').textContent = f.active || 0; + $('ovDripSub').textContent = (f.done || 0) + ' finished · ' + (f.unsubscribed || 0) + ' unsubscribed'; + const bt = Object.entries(o.byType || {}).sort((a, b) => b[1] - a[1]); + $('ovByType').innerHTML = bt.length ? bt.map(([t, n]) => '
' + esc(t) + '' + n + '
').join('') : 'No campaigns yet.'; + const ch = o.chain || {}; + $('ovChain').innerHTML = '
' + esc(ch.chainName) + ' (chain ' + esc(ch.chainId) + ')
' + + '
' + esc(ch.contract) + '
' + + (ch.explorer ? 'Open in explorer →' : ''); + } + + // ── house ads ── + const HROWS = { banner: ['hBannerRow'], text: ['hTextRow'], login: [], solo: ['hSoloRow'], video: ['hVideoRow'], featured: ['hFeatRow'], visits: ['hVisitsRow'] }; + function showHouseRows() { + const t = $('hType').value; + ['hBannerRow', 'hTextRow', 'hSoloRow', 'hVideoRow', 'hFeatRow', 'hVisitsRow'].forEach(id => { $(id).hidden = !(HROWS[t] || []).includes(id); }); + $('hBudget').hidden = t === 'featured' || t === 'visits'; + houseHints(); + } + function houseHints() { + const r = rates || {}; + const soloCost = r.soloCostPerRecipient || 5, soloMin = r.soloMinRecipients || 10; + const cap = Number($('hBudget').value) || 100000; + $('hSoloHint').textContent = 'Delivers to one inbox per ' + soloCost + ' credits of cap (minimum ' + soloMin + ' recipients). A cap of ' + cap.toLocaleString() + ' reaches up to ' + Math.floor(cap / soloCost).toLocaleString() + ' members.'; + const days = Number($('hFeatDays').value) || 0; + $('hFeatHint').textContent = days ? days + '-day run in the featured strip (' + (r.featuredPerDay || 40) + ' credits/day, free here). Book up to ' + (r.featuredWindowDays || 7) + ' days ahead.' : ''; + const n = Number($('hVisitCount').value) || 0; + $('hVisitHint').textContent = 'Packs start at ' + (r.visitMinPack || 20) + ' visits.' + (n ? ' ' + n + ' verified visits, delivered one per member.' : ''); + } + $('hType').addEventListener('change', showHouseRows); + ['hBudget', 'hFeatDays', 'hVisitCount'].forEach(id => $(id).addEventListener('input', houseHints)); + $('hFeatDays').addEventListener('change', houseHints); + $('hImageUploadBtn').addEventListener('click', () => $('hImageFile').click()); + $('hVideoUploadBtn').addEventListener('click', () => $('hVideoFile').click()); + async function upload(fileInput, info, target, kind) { + const f = fileInput.files[0]; if (!f) return; + info.textContent = 'Uploading ' + f.name + '…'; + try { + const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json(); + if (r.error) { info.textContent = r.error; } + else { target.value = r.url; info.textContent = f.name + ' uploaded'; } + } catch (e) { info.textContent = 'Upload failed. Try again.'; } + fileInput.value = ''; + } + $('hImageFile').addEventListener('change', () => upload($('hImageFile'), $('hImageInfo'), $('hImage'))); + $('hVideoFile').addEventListener('change', () => upload($('hVideoFile'), $('hVideoInfo'), $('hVideoUrl'))); + let hVidDims = null; + function probeVideoDims(url) { + return new Promise(resolve => { + const v = document.createElement('video'); v.preload = 'metadata'; v.muted = true; + const done = d => { v.src = ''; resolve(d); }; + v.onloadedmetadata = () => done(v.videoWidth && v.videoHeight ? { w: v.videoWidth, h: v.videoHeight } : null); + v.onerror = () => done(null); + setTimeout(() => done(null), 12000); + v.src = url; + }); + } + $('hCreate').addEventListener('click', busy($('hCreate'), async () => { + $('hErr').hidden = true; + const t = $('hType').value; + if (t === 'video' && $('hVideoUrl').value) hVidDims = await probeVideoDims($('hVideoUrl').value); + const days = Number($('hFeatDays').value), count = Number($('hVisitCount').value); + const body = { type: t, name: $('hName').value, targetUrl: $('hTarget').value, + imageUrl: $('hImage').value, size: $('hSize').value, + title: t === 'video' ? $('hVideoTitle').value : t === 'featured' ? $('hFeatTitle').value : t === 'visits' ? $('hVisitTitle').value : t === 'solo' ? $('hSoloTitle').value : $('hTitle').value, + body: t === 'solo' ? $('hSoloBody').value : $('hBody').value, + ctaLabel: t === 'video' ? $('hVideoCta').value : $('hSoloCta').value, + videoUrl: $('hVideoUrl').value, watchSecs: Number($('hWatchSecs').value), + videoW: hVidDims ? hVidDims.w : null, videoH: hVidDims ? hVidDims.h : null, + days, startDay: Number($('hFeatStart').value) || 0, count, + budget: t === 'featured' ? days * (rates.featuredPerDay || 40) + : t === 'visits' ? count * (rates.visitCostPerVisit || 3) + : (Number($('hBudget').value) || 0) }; + try { + await api('/api/admin/campaigns', body); + } catch (e) { $('hErr').textContent = e.message; $('hErr').hidden = false; throw e; } + IAP.status('House ad is live. It serves right away at no cost.', 'ok'); + ['hName', 'hBudget', 'hTarget', 'hImage', 'hTitle', 'hBody', 'hSoloTitle', 'hSoloBody', 'hSoloCta', + 'hVideoUrl', 'hVideoTitle', 'hVideoCta', 'hFeatTitle', 'hVisitTitle', 'hVisitCount'].forEach(id => { $(id).value = ''; }); + $('hImageInfo').textContent = ''; $('hVideoInfo').textContent = ''; hVidDims = null; + await loadHouse(); + })); + function campRow(c, showOwner) { + const left = Math.max(0, (c.budget || 0) - (c.spent || 0)); + const creative = c.type === 'banner' && c.imageUrl ? '' : esc(c.title || c.name); + const act = c.status === 'active' ? '' + : c.status === 'paused' ? '' : ''; + return '#' + c.id + (c.house ? 'HOUSE' : '') + '' + + (showOwner ? '' + esc(c.house ? 'house' : c.owner) + '' : '') + + '' + esc(c.type) + '' + + '' + esc(c.name) + '
' + creative + '
' + esc(c.targetUrl) + '' + + '' + esc(c.status) + '' + + '' + (c.spent || 0).toLocaleString() + ' / ' + (c.budget || 0).toLocaleString() + '
' + left.toLocaleString() + ' left
' + + '' + (c.imps || 0).toLocaleString() + (c.impsNas ? ' +' + c.impsNas + ' nas' : '') + '
' + (c.clicks || 0) + ' clicks
' + + '' + when(c.created) + '' + + '' + act + ''; + } + function campHead(showOwner) { + return 'ID' + (showOwner ? 'Owner' : '') + 'TypeCampaignStatusSpent / capDeliveryCreated'; + } + async function loadHouse() { + const r = await api('/api/admin/campaigns'); + rates = r.rates || rates; sizes = r.bannerSizes || sizes; houseOwner = r.houseOwner || houseOwner; + if (!$('hSize').options.length) $('hSize').innerHTML = sizes.map(s => '').join(''); + if (!$('hWatchSecs').options.length) $('hWatchSecs').innerHTML = (rates.videoTiers || []).map(t => '').join(''); + if (!$('hFeatDays').options.length) $('hFeatDays').innerHTML = (rates.featuredDurations || [1, 2, 7]).map(d => '').join(''); + showHouseRows(); + loadWallAds(); + const house = (r.campaigns || []).filter(c => c.house); + $('houseSub').textContent = house.filter(c => c.status === 'active').length + ' active · ' + house.length + ' total'; + $('houseTable').innerHTML = house.length ? campHead(false) + house.map(c => campRow(c, false)).join('') : 'No house ads yet. Place one above.'; + } + // wall fallback ads editor + let wallAds = []; + function drawWallAds() { + const w = $('wallAdsList'); + w.innerHTML = wallAds.map((a, i) => '
WALL AD ' + (i + 1) + '' + + '
' + + '

' + + '

' + + '

' + + (a.bannerUrl ? '' : '') + + '
').join('') || '

No wall ads set. Walls fall back to a plain LinkSpin card.

'; + } + function readWallAds() { + return [...document.querySelectorAll('#wallAdsList .drip-step')].map(c => ({ name: c.querySelector('.wa-name').value.trim(), targetUrl: c.querySelector('.wa-target').value.trim(), bannerUrl: c.querySelector('.wa-banner').value.trim() })); + } + async function loadWallAds() { + try { const r = await api('/api/admin/wall-ads'); wallAds = r.ads || []; $('wallAdsSub').textContent = r.usingDefaults ? 'none set: walls show the default LinkSpin card' : wallAds.length + ' in rotation'; drawWallAds(); } catch (e) {} + } + $('wallAdsList').addEventListener('click', async e => { + const up = e.target.closest('.wa-upload'); + if (up) { up.parentElement.querySelector('.wa-file').click(); return; } + const b = e.target.closest('[data-wact]'); if (!b) return; + const i = Number(b.closest('.drip-step').dataset.i); wallAds = readWallAds(); + if (b.dataset.wact === 'remove') wallAds.splice(i, 1); + if (b.dataset.wact === 'up' && i > 0) [wallAds[i - 1], wallAds[i]] = [wallAds[i], wallAds[i - 1]]; + if (b.dataset.wact === 'down' && i < wallAds.length - 1) [wallAds[i + 1], wallAds[i]] = [wallAds[i], wallAds[i + 1]]; + drawWallAds(); + }); + $('wallAdsList').addEventListener('change', async e => { + const f = e.target.closest('.wa-file'); if (!f || !f.files[0]) return; + const file = f.files[0]; const card = f.closest('.drip-step'); + try { + const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': file.type }, body: file })).json(); + if (r.error) IAP.status(r.error, 'bad'); else { card.querySelector('.wa-banner').value = r.url; IAP.status('Uploaded.', 'ok'); } + } catch (err) { IAP.status('Upload failed.', 'bad'); } + f.value = ''; + }); + $('wallAdsAdd').addEventListener('click', () => { wallAds = readWallAds(); wallAds.push({ name: '', targetUrl: '', bannerUrl: '' }); drawWallAds(); }); + $('wallAdsSave').addEventListener('click', busy($('wallAdsSave'), async () => { + $('wallAdsErr').hidden = true; + try { const r = await api('/api/admin/wall-ads', { ads: readWallAds() }, 'PATCH'); wallAds = r.ads || []; drawWallAds(); IAP.status('Wall ads saved.', 'ok'); await loadWallAds(); } + catch (e) { $('wallAdsErr').textContent = e.message; $('wallAdsErr').hidden = false; } + })); + document.addEventListener('click', async e => { + const b = e.target.closest('[data-act][data-id]'); if (!b) return; + b.disabled = true; + try { + await api('/api/admin/campaigns/' + b.dataset.id + '/' + b.dataset.act, {}); + IAP.status('Campaign #' + b.dataset.id + ' ' + (b.dataset.act === 'pause' ? 'paused' : 'resumed') + '.', 'ok'); + await Promise.all([loadHouse(), loadCampaigns()]); + } catch (err) { IAP.status(err.message, 'bad'); b.disabled = false; } + }); + + // ── all campaigns ── + let allCamps = []; + async function loadCampaigns() { + const r = await api('/api/admin/campaigns'); + allCamps = r.campaigns || []; + drawCamps(); + } + function drawCamps() { + const q = ($('campFilter').value || '').trim().toLowerCase(); + const list = allCamps.filter(c => !q || [c.owner, c.name, c.type, c.status, c.targetUrl, String(c.id)].join(' ').toLowerCase().includes(q)); + $('campSub').textContent = list.length + ' of ' + allCamps.length; + $('campTable').innerHTML = list.length ? campHead(true) + list.map(c => campRow(c, true)).join('') : 'Nothing matches.'; + } + $('campFilter').addEventListener('input', drawCamps); + + // ── members ── + let allMembers = []; + async function loadTank() { + try { + const r = await (await fetch('/api/admin/tank')).json(); if (r.error) return; + $('tankAdmSub').textContent = r.waiting.length + ' waiting · cap ' + r.cap + ' open per adopter · ' + r.ttlDays + '-day window'; + $('tankWait').innerHTML = 'WaitingEmailJoinedLast sign-in' + (r.waiting.length ? r.waiting.map(w => '' + esc(w.name) + '' + esc(w.email) + '' + when(w.joined) + '' + (w.lastSeen ? when(w.lastSeen) : 'never') + '').join('') : 'empty'); + $('tankAdopt').innerHTML = 'MemberAdopted byWhenWindow endsStatus' + (r.adoptions.length ? r.adoptions.map(a => '' + esc(a.adopteeName) + '' + esc(a.adopterName) + '' + when(a.ts) + '' + (a.status === 'released' ? '' : when(a.expires)) + '' + esc(a.status) + '').join('') : 'none yet'); + } catch (e) {} + } + async function loadMembers() { + loadTank(); + const r = await api('/api/admin/members'); + allMembers = r.members || []; + drawMembers(); + } + function drawMembers() { + const q = ($('memFilter').value || '').trim().toLowerCase(); + const list = allMembers.filter(a => !q || [a.email, a.username, a.memberId, a.sponsorRef, a.address, a.code].join(' ').toLowerCase().includes(q)); + $('memSub').textContent = list.length + ' of ' + allMembers.length; + $('memTable').innerHTML = 'EmailUsernameMember #WalletSponsorPositionsViaCodeJoined' + + list.map(a => '' + esc(a.email) + '' + (a.username ? '@' + esc(a.username) : 'none') + '' + + '' + (a.memberId ? '#' + a.memberId : 'free') + '' + + '' + (a.address ? esc(a.address.slice(0, 8) + '…' + a.address.slice(-6)) : 'none') + '' + + '' + (a.sponsorName ? esc(a.sponsorName) + (a.sponsorVia === 'code' ? ' via code ' + esc(a.sponsorRef) + '' : a.sponsorVia === 'member #' ? ' via #' + esc(a.sponsorRef) + '' : '') : a.sponsorRef ? 'dead link: ' + esc(a.sponsorRef) + '' : 'none') + '' + (a.positions ? a.positions : '0') + '' + esc(a.joinedVia || '') + '' + esc(a.code || '') + '' + + '' + when(a.created) + '' + + ' ').join(''); + } + $('memFilter').addEventListener('input', drawMembers); + document.addEventListener('click', async e => { + const b = e.target.closest('[data-spon]'); if (!b) return; + const v = await IAP.ask({ title: 'Sponsor for ' + b.dataset.spon, text: 'Username, share code, or member #. Leave blank to clear.', value: b.dataset.cur, ok: 'Save' }); + if (v === null || v === undefined) return; + try { + await api('/api/admin/members', { email: b.dataset.spon, sponsorRef: v.trim() }, 'PATCH'); + IAP.status('Sponsor updated.', 'ok'); + await loadMembers(); + } catch (err) { IAP.status(err.message, 'bad'); } + }); + + // ── member card: search, drill down, act (Marty, 2026-09-13) ── + let mcCur = null; + const polOf = w => { try { return (Number(BigInt(w || '0') / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }); } catch (e) { return '0'; } }; + const ago = ts => { if (!ts) return 'never'; const d = Date.now() - Number(ts); const h = Math.floor(d / 3600000); return h < 1 ? Math.max(1, Math.floor(d / 60000)) + ' min ago' : h < 48 ? h + ' h ago' : Math.floor(h / 24) + ' days ago'; }; + const memLink = (email, label) => '' + esc(label) + ''; + async function openMember(q) { + const msg = $('memSearchMsg'); msg.hidden = true; + let d; + try { d = await api('/api/admin/member?q=' + encodeURIComponent(q)); } catch (e) { msg.textContent = e.message; msg.hidden = false; msg.className = 'small bad'; return; } + renderMember(d); + } + function kv(rows) { return '' + rows.map(r => '').join('') + '
' + r[0] + '' + r[1] + '
'; } + function renderMember(d) { + mcCur = d; const a = d.account; $('memHits').hidden = true; + $('memCard').hidden = false; document.querySelectorAll('#pane-members > .card').forEach(c => { if (c.id !== 'memCard' && c.id !== 'memSearchCard') c.hidden = true; }); + $('mcName').textContent = (a.username ? '@' + a.username : a.email) + (a.memberId ? ' · member #' + a.memberId : ' · free member'); + $('mcSub').textContent = 'joined ' + when(a.created) + ' · last seen ' + ago(a.lastSeen); + $('mcWall').hidden = !a.username; if (a.username) $('mcWall').href = '/wall/' + a.username; + const ch = d.chain, cr = d.credits, t = d.totals; + const level = ch && !ch.readError ? (ch.buyerCount >= 5 ? 'level 3 (5+ buyers)' : ch.buyerCount >= 2 ? 'level 2 (2 buyers)' : 'level 1') : ''; + let h = '
'; + h += '

Identity

' + kv([ + ['Email', esc(a.email)], ['Username', a.username ? '@' + esc(a.username) : 'not set'], ['Share code', esc(a.code || '')], + ['Main wallet', a.address ? '' + esc(a.address) + '' : 'none linked'], + ['Extra positions', d.positions.length ? d.positions.map(p => '' + esc(p.address.slice(0, 8) + '…' + p.address.slice(-6)) + '' + (p.memberId ? ' = #' + p.memberId : ' (unregistered)')).join('
') : 'none'], + ['Sponsor (site)', d.upline.length ? memLink(d.upline[0].email, d.upline[0].name) + ' token ' + esc(a.sponsorRef || '') + '' : (a.sponsorRef ? 'unresolved: ' + esc(a.sponsorRef) + '' : 'none (company)')], + ['Upline chain', d.upline.length > 1 ? d.upline.map(u => memLink(u.email, u.name)).join(' → ') : '-'], + ['Joined via', esc(a.joinedVia || 'join page') + (a.joinedRef ? ' from ' + esc(a.joinedRef) : '')], + ['Line banner', a.lineTargetUrl ? '' + esc(a.lineTargetUrl.slice(0, 50)) + '' : 'not set'], + ['Chat', a.chatAvailable ? 'available' : 'switched off']]) + '
'; + h += '

On-chain and money

' + kv([ + ['Registered', ch ? (ch.readError ? 'read error' : 'yes, #' + ch.memberId + ' under ' + (ch.sponsorId ? '#' + ch.sponsorId + (d.names[ch.sponsorId] ? ' @' + esc(d.names[ch.sponsorId]) : '') : 'nobody')) : 'no (payouts off)'], + ['Qualifying buyers', ch && !ch.readError ? ch.buyerCount + ' · ' + level : '-'], + ['Packages bought', t.purchases + (t.purchases ? ' · $' + (t.spentCents / 100).toFixed(0) + ' · ' + polOf(t.spentWei) + ' POL' : '')], + ['Payouts received', t.payoutsIn + (t.payoutsIn ? ' · ' + polOf(t.receivedWei) + ' POL' : '')], + ['Credits', cr ? cr.available.toLocaleString() + ' available · ' + cr.inCampaigns.toLocaleString() + ' in campaigns · ' + cr.total.toLocaleString() + ' total' : '-'], + ['Earned pool', d.earnedSplit ? d.earnedSplit.total.toLocaleString() + ' (' + (d.earnedSplit.grade || 0).toLocaleString() + ' purchased-grade)' : '-'], + ['Old-site account', d.legacy ? 'had a ' + (d.legacy.brand === 'both' ? 'Faucet Wave and Tier One Ads' : d.legacy.brand === 'tier1ads' ? 'Tier One Ads' : 'Faucet Wave') + ' account (' + d.legacy.seg + ') · welcome-back credits ' + (d.legacy.grant ? d.legacy.grant.credits + ' issued ' + when(d.legacy.grant.at) : 'not issued (joined outside the legacy bridge)') : 'none on record'], + ['Promo codes', d.promos.length ? d.promos.map(p => esc(p.code) + ' (' + p.credits + ', ' + when(p.ts) + ')').join('
') : 'none'], + ['Drip', d.drip ? (d.drip.stopped ? 'stopped' : 'step ' + d.drip.step + ', next ' + when(d.drip.next_at)) + (d.drip.angle ? ' · ' + esc(d.drip.angle) : '') : '-'], + ['Holding tank', d.tank ? (d.tank.waiting ? 'waiting for a sponsor' : 'not in tank') + (d.tank.adoptedBy.length ? ' · adopted by ' + d.tank.adoptedBy.map(x => memLink(x.email, x.name)).join(', ') : '') + (d.tank.adopted.length ? ' · adopted ' + d.tank.adopted.map(x => memLink(x.email, x.name)).join(', ') : '') : '-'], + ['Earning', d.earning ? 'today ' + d.earning.today + '/5' + (d.earning.claimed ? ' claimed' : '') + ' · streak day ' + d.earning.streakDay + (d.activeDays14 !== undefined ? ' · active ' + d.activeDays14 + ' of last 14 days, ' + d.claims14 + ' claims' : '') : '-'], + ['Visits / videos / chat', (d.visits || 0) + ' verified visits · ' + (d.videos || 0) + ' video watches · ' + (d.messageCount || 0) + ' messages']]) + '
'; + // line + h += '

Line (' + d.lineCounts.join(' / ') + ')

'; + if (!d.line.length) h += '

Nobody in their line yet.

'; + for (const L of d.line) { + h += '

Level ' + L.level + ' · ' + L.members.length + '

' + + L.members.map(m => '').join('') + '
MemberMember #WalletBoughtQualifiedJoinedLast seen
' + memLink(m.email, m.name) + (L.level === 1 ? '
' + esc(m.email) + '' : '') + '
' + (m.memberId ? '#' + m.memberId : 'free') + '' + (m.wallet ? 'yes' : 'no') + '' + (m.bought ? 'yes' : 'no') + '' + (m.qualified ? 'yes' : '') + '' + when(m.joined) + '' + ago(m.lastSeen) + '
'; + } + // purchases + payouts + campaigns + h += '

Purchases

' + + (d.purchases.length ? d.purchases.map(p => '').join('') : '') + '
WhenPositionPackagePaidTx
' + when(p.ts) + '#' + p.buyerId + '$' + (p.priceCents / 100).toFixed(0) + ' · ' + Number(p.credits || 0).toLocaleString() + ' cr' + polOf(p.paidWei) + ' POL' + esc(p.tx.slice(0, 10)) + '…
No purchases.
'; + h += '

Payouts received

' + + (d.received.length ? d.received.map(r => '').join('') : '') + '
WhenFromTierAmount
' + when(r.ts) + '#' + r.buyerId + (d.names[r.buyerId] ? ' @' + esc(d.names[r.buyerId]) : '') + '' + r.tier + '' + polOf(r.amountWei) + ' POL
Nothing received yet.
'; + h += '

Campaigns (' + d.campaigns.length + ')

' + + (d.campaigns.length ? d.campaigns.map(c => '').join('') : '') + '
#TypeStatusBudgetSpentViewsClicksCreated
' + c.id + '' + esc(c.type) + '' + esc(c.status) + '' + Number(c.budget || 0).toLocaleString() + '' + Number(c.spent || 0).toLocaleString() + '' + Number(c.views || 0).toLocaleString() + '' + Number(c.clicks || 0).toLocaleString() + '' + when(c.created) + '
No campaigns.
'; + $('mcBody').innerHTML = h; + if (location.hash !== '#members') history.replaceState(null, '', '#members'); + } + function closeMember() { $('memCard').hidden = true; document.querySelectorAll('#pane-members > .card').forEach(c => { c.hidden = false; }); } + // live matches while typing: any part of the username, email, member #, share code or wallet + let memHitList = []; + function memMatches(q) { + q = q.toLowerCase(); + return allMembers.filter(a => [a.username, a.email, a.memberId ? '#' + a.memberId : '', a.memberId, a.code, a.address, a.sponsorName].filter(Boolean).join(' ').toLowerCase().includes(q)).slice(0, 12); + } + async function memTypeahead() { + const q = $('memSearch').value.trim(); + if (!allMembers.length) { try { const r = await api('/api/admin/members'); allMembers = r.members || []; } catch (e) {} } + if (q.length < 2) { $('memHits').hidden = true; memHitList = []; return; } + memHitList = memMatches(q); + $('memHits').innerHTML = memHitList.length ? memHitList.map(a => '').join('') + : '

No member matches that.

'; + $('memHits').hidden = false; + } + $('memSearch').addEventListener('input', memTypeahead); + $('memSearch').addEventListener('focus', memTypeahead); + $('memOpen').addEventListener('click', () => { const q = $('memSearch').value.trim(); if (!q) return; if (memHitList.length) openMember(memHitList[0].email); else openMember(q); }); + $('memSearch').addEventListener('keydown', e => { if (e.key === 'Enter') $('memOpen').click(); if (e.key === 'Escape') $('memHits').hidden = true; }); + document.addEventListener('click', e => { if (!e.target.closest('#memSearchCard')) $('memHits').hidden = true; }); + $('mcBack').addEventListener('click', closeMember); + document.addEventListener('click', e => { const l = e.target.closest('[data-mcopen]'); if (l) { e.preventDefault(); openMember(l.dataset.mcopen); } }); + document.querySelectorAll('[data-mcact]').forEach(b => b.addEventListener('click', busy(b, async () => { + if (!mcCur) return; const a = mcCur.account, act = b.dataset.mcact; let body = null; + if (act === 'username') { const v = await IAP.ask({ title: 'Username for ' + a.email, text: '3-20 letters, numbers or underscore. Changing it breaks any invite links they already handed out.', value: a.username || '', ok: 'Save' }); if (v === null || v === undefined) return; body = { username: v }; } + if (act === 'sponsor') { const v = await IAP.ask({ title: 'Sponsor for ' + (a.username ? '@' + a.username : a.email), text: 'Username, share code or member #. Blank = no sponsor (company). Re-points free referrals and future purchases; on-chain sponsorship never changes.', value: a.sponsorRef || '', ok: 'Save' }); if (v === null || v === undefined) return; body = { sponsorRef: v }; } + if (act === 'wallet') { const v = await IAP.ask({ title: 'Main wallet for ' + a.email, text: 'Paste the 0x address that should be their main wallet (the one that paid, if a purchase came from an unlinked account). The member number is re-read from the chain. Blank = unlink.', value: a.address || '', ok: 'Swap' }); if (v === null || v === undefined) return; if (!await IAP.confirmBox('Swap the main wallet for ' + a.email + ' to ' + (v.trim() || 'nothing') + '?', { title: 'Sure?', ok: 'Swap it', cancel: 'Cancel' })) return; body = { address: v }; } + if (act === 'credits') { const v = await IAP.ask({ title: 'Grant credits to ' + (a.username ? '@' + a.username : a.email), text: 'Whole number of earned-pool credits (1 credit = 1 cent of delivery). They can spend them on campaigns right away.', type: 'number', value: '', placeholder: '250', ok: 'Grant' }); if (!v) return; const note = await IAP.ask({ title: 'Reason (kept in the server log)', value: '', placeholder: 'e.g. refund for broken banner', ok: 'Grant' }); body = { grantCredits: v, note: note || '' }; } + if (act === 'delete') { + if (a.memberId) { IAP.status('Registered members cannot be deleted; their position is on-chain.', 'bad'); return; } + if (!await IAP.confirmBox('Delete the free account ' + a.email + '? Their sign-in, referrals link and credits go away. There is no undo.', { title: 'Delete account', ok: 'Delete', cancel: 'Keep it' })) return; + await api('/api/admin/member?email=' + encodeURIComponent(a.email), undefined, 'DELETE'); IAP.status('Account deleted.', 'ok'); closeMember(); loadMembers().catch(() => {}); return; + } + if (!body) return; + const d = await api('/api/admin/member', Object.assign({ email: a.email }, body), 'PATCH'); + renderMember(d); IAP.status('Saved.', 'ok'); loadMembers().catch(() => {}); + }))); + + // ── every admin table: click a header to sort (numbers sort as numbers), inputs with + // class "tfilter" filter the table named in data-for ── + document.addEventListener('click', e => { + const th = e.target.closest('.adm-table th'); if (!th || th.closest('table').classList.contains('kv')) return; + const table = th.closest('table'), hdr = th.parentElement, idx = [...hdr.children].indexOf(th); + const rows = [...table.querySelectorAll('tr')].filter(r => r !== hdr && r.children.length > 1); + const num = s => { const t = String(s).replace(/[$,%\s]/g, '').replace(/…$/, ''); return t !== '' && !isNaN(t) ? Number(t) : null; }; + const dir = th.dataset.dir === 'asc' ? 'desc' : 'asc'; + hdr.querySelectorAll('th').forEach(x => { delete x.dataset.dir; x.classList.remove('sort-asc', 'sort-desc'); }); + th.dataset.dir = dir; th.classList.add('sort-' + dir); + rows.sort((r1, r2) => { const a = (r1.children[idx] || {}).textContent || '', b = (r2.children[idx] || {}).textContent || ''; const na = num(a), nb = num(b); const c = na !== null && nb !== null ? na - nb : a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); return dir === 'asc' ? c : -c; }); + rows.forEach(r => (hdr.parentElement).appendChild(r)); + }); + document.addEventListener('input', e => { + const inp = e.target.closest('.tfilter'); if (!inp) return; + const table = $(inp.dataset.for); if (!table) return; + const q = inp.value.trim().toLowerCase(); let shown = 0; + [...table.querySelectorAll('tr')].forEach((r, i) => { if (i === 0 || r.querySelector('th')) return; const hit = !q || r.textContent.toLowerCase().includes(q); r.hidden = !hit; if (hit) shown++; }); + const c = inp.parentElement.querySelector('.tfilter-count'); if (c) c.textContent = q ? shown + ' shown' : ''; + }); + + // ── release notes + roadmap (Marty, 2026-09-14) ── + async function loadReleases() { + loadUpdates(); + const d = await api('/api/admin/releases'); + $('rnSub').textContent = d.notes.length + ' notes'; $('rmSub').textContent = d.roadmap.length + ' items'; + $('rnTable').innerHTML = 'DateTitleTags' + (d.notes.length ? d.notes.map(n => '' + esc(n.date) + '' + esc(n.title) + '' + esc(n.tags.join(', ')) + ' ').join('') : 'No notes yet.'); + $('rmTable').innerHTML = 'StatusTitleETA#' + (d.roadmap.length ? d.roadmap.map(r => '' + esc(r.status) + '' + esc(r.title) + '' + (r.note ? '
' + esc(r.note) + '' : '') + '' + esc(r.eta || '') + '' + (r.order || '') + ' ').join('') : 'Nothing on the roadmap yet.'); + $('rnTable').querySelectorAll('[data-rnedit]').forEach(b => b.addEventListener('click', () => { const n = d.notes.find(x => x.id === b.dataset.rnedit); if (!n) return; $('rnId').value = n.id; $('rnTitle').value = n.title; $('rnDate').value = n.date; $('rnTags').value = n.tags.join(', '); $('rnBody').value = n.body; $('rnTitle').focus(); })); + $('rmTable').querySelectorAll('[data-rmedit]').forEach(b => b.addEventListener('click', () => { const r = d.roadmap.find(x => x.id === b.dataset.rmedit); if (!r) return; $('rmId').value = r.id; $('rmTitle').value = r.title; $('rmStatus').value = r.status; $('rmEta').value = r.eta || ''; $('rmOrder').value = r.order || ''; $('rmNote').value = r.note || ''; $('rmTitle').focus(); })); + $('rnTable').querySelectorAll('[data-rndel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this release note?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=notes&id=' + encodeURIComponent(b.dataset.rndel), undefined, 'DELETE'); loadReleases(); })); + $('rmTable').querySelectorAll('[data-rmdel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this roadmap item?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=roadmap&id=' + encodeURIComponent(b.dataset.rmdel), undefined, 'DELETE'); loadReleases(); })); + } + // ── member update emails (Marty, 2026-09-14) ── + async function loadUpdates() { + if (!$('updCard')) return; + try { + const d = await api('/api/admin/updates'); + $('updSub').textContent = (d.mailer ? '' : 'mailer not configured · ') + (d.lastSentAt ? 'last send ' + new Date(d.lastSentAt).toLocaleString() : 'nothing sent yet') + (d.running ? ' · sending now' : ''); + $('updAudience').innerHTML = Object.entries(d.audiences).map(([k, v]) => '').join(''); + $('updNotes').innerHTML = d.notes.length ? d.notes.map(n => '').join('') : 'No release notes yet.'; + $('updLog').innerHTML = 'WhenSubjectAudienceSent' + (d.sends.length ? d.sends.map(x => '' + new Date(x.ts).toLocaleString() + '' + esc(x.subject) + '' + esc(d.audiences[x.audience] || x.audience) + '' + x.sent + ' of ' + x.total + (x.skipped ? ' · ' + x.skipped + ' opted out' : '') + (x.failed ? ' · ' + x.failed + ' failed' : '') + (x.unknown ? ' · ' + x.unknown + ' unchecked (Sendy gave no answer)' : '') + (x.status === 'running' ? ' · running' : '') + '').join('') : 'None yet.'); + if (d.draft && !updDraftLoaded) { updDraftLoaded = true; $('updSubject').value = d.draft.subject || ''; $('updIntro').value = d.draft.intro || ''; $('updClosing').value = d.draft.closing || ''; if (d.draft.audience) $('updAudience').value = d.draft.audience; $('updNotes').querySelectorAll('input').forEach(i => { i.checked = (d.draft.noteIds || []).includes(i.value); }); $('updSub').textContent += ' · draft loaded (saved ' + new Date(d.draft.savedAt).toLocaleString() + ')'; } + if (d.running) setTimeout(loadUpdates, 4000); + } catch (e) { $('updSub').textContent = e.message; } + } + let updDraftLoaded = false; + const updInput = () => ({ subject: $('updSubject').value, intro: $('updIntro').value, closing: $('updClosing').value, noteIds: [...$('updNotes').querySelectorAll('input:checked')].map(i => i.value), audience: $('updAudience').value }); + if ($('updCard')) { + $('updSaveDraft').addEventListener('click', busy($('updSaveDraft'), async () => { await api('/api/admin/updates/draft', updInput()); IAP.status('Draft saved.', 'ok'); })); + $('updPreview').addEventListener('click', busy($('updPreview'), async () => { const r = await api('/api/admin/updates/preview', updInput()); $('updPre').hidden = false; $('updPre').textContent = 'Subject: ' + r.subject + '\n\n' + r.text; })); + $('updTest').addEventListener('click', busy($('updTest'), async () => { const r = await api('/api/admin/updates/send', Object.assign(updInput(), { test: true })); IAP.status('Test sent to ' + r.to + '.', 'ok'); })); + $('updSend').addEventListener('click', busy($('updSend'), async () => { + const inp = updInput(); if (!inp.noteIds.length) { IAP.status('Pick at least one note.', 'bad'); return; } + const opt = $('updAudience').selectedOptions[0].textContent; + if (!(await IAP.confirmBox('Send this update to ' + opt + '? One email per member, opt-outs skipped.', { title: 'Send member update', ok: 'Send now', cancel: 'Not yet' }))) return; + const r = await api('/api/admin/updates/send', inp); IAP.status('Sending to ' + r.total + ' members in the background.', 'ok'); loadUpdates(); + })); + } + $('rnClear').addEventListener('click', () => { ['rnId', 'rnTitle', 'rnDate', 'rnTags', 'rnBody'].forEach(id => { $(id).value = ''; }); }); + $('rmClear').addEventListener('click', () => { ['rmId', 'rmTitle', 'rmEta', 'rmOrder', 'rmNote'].forEach(id => { $(id).value = ''; }); $('rmStatus').value = 'planned'; }); + $('rnSave').addEventListener('click', busy($('rnSave'), async () => { await api('/api/admin/releases', { kind: 'note', id: $('rnId').value || null, title: $('rnTitle').value, date: $('rnDate').value, tags: $('rnTags').value, body: $('rnBody').value }); IAP.status('Note saved.', 'ok'); $('rnClear').click(); loadReleases(); })); + $('rmSave').addEventListener('click', busy($('rmSave'), async () => { await api('/api/admin/releases', { kind: 'roadmap', id: $('rmId').value || null, title: $('rmTitle').value, status: $('rmStatus').value, eta: $('rmEta').value, order: $('rmOrder').value, note: $('rmNote').value }); IAP.status('Roadmap item saved.', 'ok'); $('rmClear').click(); loadReleases(); })); + + // ── reports + burns ── + // ── profit and loss ── + let pnlDays = 30; + const pol = w => { try { return (Number(BigInt(w || '0') / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }); } catch (e) { return '0'; } }; + const usdOf = (w, px) => { try { return '$' + ((Number(BigInt(w || '0') / 10n ** 14n) / 10000) * px).toLocaleString(undefined, { maximumFractionDigits: 0 }); } catch (e) { return '$0'; } }; + // ── traffic: referring domains / sources, landing pages, angles, by day ── + let trfDays = 30; + document.querySelectorAll('#trfRange [data-days]').forEach(b => b.addEventListener('click', () => { trfDays = Number(b.dataset.days); document.querySelectorAll('#trfRange [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadTraffic().catch(e => IAP.status(e.message, 'bad')); })); + async function loadPromos() { + const d = await (await fetch('/api/admin/promos')).json(); + if (d.error) throw new Error(d.error); + const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const when = t => t ? new Date(t).toLocaleDateString() : ''; + $('pcTable').innerHTML = 'CodeCreditsPartnerUsesMaxExpiresStatus' + + (d.codes.length ? d.codes.map(c => '' + esc(c.code) + '' + c.credits.toLocaleString() + '' + esc(c.partner) + '' + c.uses + '' + (c.maxUses || '∞') + '' + (c.expires ? when(c.expires) : '') + '' + (c.active ? 'active' : 'off') + '').join('') : 'No codes yet.'); + $('pcRecent').innerHTML = 'WhenCodeEmailCreditsVia' + + (d.recent.length ? d.recent.map(r => '' + new Date(r.ts).toLocaleString() + '' + esc(r.code) + '' + esc(r.email) + '' + r.credits + '' + esc(r.via) + '').join('') : 'No redemptions yet.'); + document.querySelectorAll('[data-pctoggle]').forEach(b => b.addEventListener('click', async () => { + try { await api('/api/admin/promos', { code: b.dataset.pctoggle, active: b.dataset.on === '1' }, 'PATCH'); loadPromos(); } catch (e) { IAP.status(e.message, 'bad'); } + })); + } + if ($('pcSave')) $('pcSave').addEventListener('click', async () => { + const msg = $('pcMsg'); msg.hidden = false; + try { + const r = await api('/api/admin/promos', { code: $('pcCode').value, credits: $('pcCredits').value, partner: $('pcPartner').value, maxUses: $('pcMax').value, expires: $('pcExpires').value || null, active: true }); + msg.textContent = 'Saved ' + r.code.code + ': ' + r.code.credits + ' credits.'; msg.style.color = 'var(--mint)'; + $('pcCode').value = ''; $('pcCredits').value = ''; $('pcPartner').value = ''; loadPromos(); + } catch (e) { msg.textContent = e.message; msg.style.color = '#ff8a8a'; } + }); + async function loadTraffic() { + loadPromos().catch(e => IAP.status(e.message, 'bad')); + const d = await (await fetch('/api/admin/traffic?days=' + trfDays)).json(); + if (d.error) throw new Error(d.error); + const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const n = v => Number(v || 0).toLocaleString(); + $('trfSub').textContent = 'last ' + d.days + ' days · ' + n(d.totals.hits) + ' page views · ' + n(d.totals.joinViews) + ' join-page views · ' + n(d.totals.signups) + ' signups · ' + n(d.totals.buyers) + ' buyers'; + // conversion columns (Marty, 2026-09-13): visits = page views + join-page views; signup rate is per visit, + // registered and buyer rates are per signup (what happened to the people who did sign up) + const pct = (num, den) => den ? (100 * num / den).toFixed(num && 100 * num / den < 10 ? 1 : 0) + '%' : '-'; + $('trfSources').innerHTML = 'SourcePage
viewsJoin-page
viewsSignupsVisit →
signupRegisteredSignup →
registered$20+
buyersSignup →
buyer' + + (d.sources.length ? d.sources.map(s => '' + esc(s.source) + '' + n(s.hits) + '' + n(s.joinViews) + '' + n(s.signups) + '' + pct(s.signups, s.hits + s.joinViews) + '' + n(s.registered) + '' + pct(s.registered, s.signups) + '' + n(s.buyers) + '' + pct(s.buyers, s.signups) + '').join('') : 'Nothing recorded in this range yet.'); + $('trfPaths').innerHTML = 'PageViews' + (d.paths.length ? d.paths.map(p => '' + esc(p.path) + '' + n(p.hits) + '').join('') : 'No page views yet.'); + $('trfAngles').innerHTML = 'AngleJoin-page
viewsSignupsView →
signup' + (d.angles.length ? d.angles.map(a => '' + esc(a.angle) + '' + n(a.views) + '' + n(a.signups) + '' + pct(a.signups, a.views) + '').join('') : 'No angle data yet.'); + $('trfDaily').innerHTML = 'DayPage viewsSignups' + (d.daily.length ? d.daily.slice().reverse().map(x => '' + esc(x.day) + '' + n(x.hits) + '' + n(x.signups) + '').join('') : 'Nothing yet.'); + } + // ── blog: coaching articles, public at /blog (Marty, 2026-09-12) ── + let blCur = null; // slug being edited, or null for a new one + function blCount() { + const t = $('blTitle').value.length, e = $('blExcerpt').value.length; + $('blTitleCount').textContent = t + '/60' + (t > 60 ? ' (long)' : ''); + $('blExcCount').textContent = e + ' chars' + (e && (e < 120 || e > 160) ? ' (aim 120-160)' : ''); + const w = $('blBody').textContent.trim().split(/\s+/).filter(Boolean).length; + $('blWords').textContent = w + ' words'; + } + ['blTitle', 'blExcerpt'].forEach(id => $(id).addEventListener('input', blCount)); + $('blBody').addEventListener('input', blCount); + $('blTitle').addEventListener('input', () => { if (!blCur && !$('blSlug').dataset.touched) $('blSlug').value = $('blTitle').value.toLowerCase().replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80); }); + $('blSlug').addEventListener('input', () => { $('blSlug').dataset.touched = '1'; }); + document.querySelectorAll('.ed-bar [data-bl]').forEach(b => b.addEventListener('click', () => { $('blBody').focus(); document.execCommand(b.dataset.bl, false, null); })); + document.querySelectorAll('.ed-bar [data-blblock]').forEach(b => b.addEventListener('click', () => { $('blBody').focus(); document.execCommand('formatBlock', false, b.dataset.blblock); })); + $('blLinkBtn').addEventListener('click', async () => { + const sel = window.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null; + const u = await IAP.ask({ title: 'Link address', label: 'https://', placeholder: 'https://linkspin-test.saasy.top/join/martbost', ok: 'Insert' }); + if (u) { $('blBody').focus(); if (range) { sel.removeAllRanges(); sel.addRange(range); } document.execCommand('createLink', false, u); } + }); + $('blImgBtn').addEventListener('click', () => $('blImgFile').click()); + $('blImgFile').addEventListener('change', async () => { + const f = $('blImgFile').files[0]; if (!f) return; + try { + const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json(); + if (r.error) throw new Error(r.error); + $('blBody').focus(); + const html = ''; + if (!document.execCommand('insertHTML', false, html)) $('blBody').insertAdjacentHTML('beforeend', html); + blCount(); + } catch (e) { IAP.status(e.message || 'Upload failed.', 'bad'); } + $('blImgFile').value = ''; + }); + $('blCoverBtn').addEventListener('click', () => $('blCoverFile').click()); + $('blCoverFile').addEventListener('change', () => upload($('blCoverFile'), $('blCoverInfo'), $('blCover'))); + $('blHtmlBtn').addEventListener('click', () => { + const raw = !$('blHtml').hidden; + if (raw) { $('blBody').innerHTML = $('blHtml').value; $('blHtml').hidden = true; $('blBody').hidden = false; } + else { $('blHtml').value = $('blBody').innerHTML; $('blBody').hidden = true; $('blHtml').hidden = false; } + blCount(); + }); + function blBodyHtml() { return $('blHtml').hidden ? $('blBody').innerHTML : $('blHtml').value; } + function blMsg(t, bad) { $('blMsg').textContent = t; $('blMsg').hidden = !t; $('blMsg').className = 'small ' + (bad ? 'bad' : 'ok'); } + function blOpen(post) { + blCur = post ? post.slug : null; + $('blogList').hidden = true; $('blogEditor').hidden = false; + $('blEdTitle').textContent = post ? 'Edit article' : 'New article'; + $('blEdSub').textContent = post ? (post.status === 'published' ? 'published ' + when(post.publishedAt) + ' · ' + (post.views || 0) + ' views' : 'draft') : ''; + $('blTitle').value = post ? post.title : ''; $('blSlug').value = post ? post.slug : ''; delete $('blSlug').dataset.touched; + $('blTags').value = post ? post.tags.join(', ') : ''; $('blExcerpt').value = post ? post.excerpt : ''; $('blCover').value = post ? post.cover : ''; $('blCoverInfo').textContent = ''; + $('blHtml').hidden = true; $('blBody').hidden = false; $('blBody').innerHTML = post ? post.body : ''; + $('blUnpublish').hidden = !(post && post.status === 'published'); $('blDelete').hidden = !post; + $('blPreview').hidden = !post; if (post) $('blPreview').href = '/blog/' + post.slug; + $('blPublish').textContent = post && post.status === 'published' ? 'Save and publish' : 'Publish'; + blMsg(''); blCount(); $('blTitle').focus(); + } + async function blSave(status) { + const body = { existingSlug: blCur, title: $('blTitle').value, slug: $('blSlug').value, tags: $('blTags').value, excerpt: $('blExcerpt').value, cover: $('blCover').value, body: blBodyHtml(), status }; + const r = await api('/api/admin/blog', body); + blCur = r.post.slug; + $('blSlug').value = r.post.slug; $('blPreview').hidden = false; $('blPreview').href = '/blog/' + r.post.slug; $('blDelete').hidden = false; + $('blUnpublish').hidden = r.post.status !== 'published'; $('blPublish').textContent = r.post.status === 'published' ? 'Save and publish' : 'Publish'; + $('blEdTitle').textContent = 'Edit article'; + blMsg(r.post.status === 'published' ? 'Published. Live at linkspin-test.saasy.top/blog/' + r.post.slug + (r.syndicating ? ' · posting to X and Instagram now (see the Social column in the list).' : '') : 'Draft saved.'); + IAP.status(r.post.status === 'published' ? 'Published.' : 'Draft saved.', 'ok'); + } + $('blSaveDraft').addEventListener('click', busy($('blSaveDraft'), () => blSave('draft'))); + $('blPublish').addEventListener('click', busy($('blPublish'), () => blSave('published'))); + $('blUnpublish').addEventListener('click', busy($('blUnpublish'), () => blSave('draft'))); + $('blClose').addEventListener('click', () => { $('blogEditor').hidden = true; $('blogList').hidden = false; loadBlog().catch(e => IAP.status(e.message, 'bad')); }); + $('blDelete').addEventListener('click', busy($('blDelete'), async () => { + if (!blCur) return; + if (!await IAP.confirmBox('The page at /blog/' + blCur + ' stops existing. There is no undo.', { title: 'Delete this article?', ok: 'Delete', cancel: 'Keep it' })) return; + await api('/api/admin/blog?slug=' + encodeURIComponent(blCur), undefined, 'DELETE'); + $('blClose').click(); + })); + $('blNew').addEventListener('click', () => blOpen(null)); + async function loadBlog() { + const d = await api('/api/admin/blog'); + const pub = d.posts.filter(p => p.status === 'published').length; + $('blSub').textContent = pub + ' published · ' + (d.posts.length - pub) + ' drafts'; + const synd = p => { const s = p.syndicated; if (!s) return p.status === 'published' ? 'not posted' : ''; const r = s.results || {}; const part = ['x', 'instagram'].map(k => r[k] ? (r[k].ok ? k + ' ✓' : k + ' ✗') : k + ' –').join(' · '); return '' + part + ''; }; + $('blSyndNote').hidden = false; $('blSyndNote').textContent = d.syndication ? 'Publishing an article posts it to X (@cryptoteambuild) and Instagram (marketingwithmarty) through Blotato, once per article, with the cover image.' : 'Social syndication is off: no Blotato key on the server.'; + $('blTable').innerHTML = 'TitleStatusSocialTagsViewsUpdated' + + (d.posts.length ? d.posts.map(p => '' + esc(p.title) + '
/blog/' + esc(p.slug) + '' + (p.status === 'published' ? 'published' : 'draft') + '' + synd(p) + '' + esc(p.tags.join(', ')) + '' + (p.views || 0) + '' + when(p.updated) + ' View' + (p.status === 'published' && d.syndication && !(p.syndicated && p.syndicated.done) ? ' ' : '') + '').join('') + : 'No articles yet. Start with "New article".'); + $('blTable').querySelectorAll('[data-blsynd]').forEach(b => b.addEventListener('click', busy(b, async () => { + const r = await api('/api/admin/blog/syndicate', { slug: b.dataset.blsynd }); + const res = r.syndicated && r.syndicated.results || {}; const bad = Object.entries(res).filter(([, v]) => !v.ok).map(([k, v]) => k + ': ' + v.error); + IAP.status(bad.length ? 'Posted with problems: ' + bad.join(' | ') : 'Posted to X and Instagram.', bad.length ? 'bad' : 'ok'); loadBlog().catch(() => {}); + }))); + $('blTable').querySelectorAll('[data-bledit]').forEach(b => b.addEventListener('click', async () => { + try { const r = await api('/api/admin/blog?slug=' + encodeURIComponent(b.dataset.bledit)); blOpen(r.post); } catch (e) { IAP.status(e.message, 'bad'); } + })); + } + + async function loadPnl() { + const r = await api('/api/admin/pnl?days=' + pnlDays); + const px = r.polUsd || 0; + const platUsd = (Number(BigInt(r.platformWei || '0') / 10n ** 14n) / 10000) * px; + const months = pnlDays ? pnlDays / 30 : Math.max(1, (r.latest - r.fromBlock) / 43200 / 30); + const fixed = (r.fixedMonthlyUsd || 0) * months; + $('pnlTiles').innerHTML = [ + ['Packages sold', r.purchases.count, Object.entries(r.purchases.byPackage || {}).map(([k, v]) => v + '×' + k).join(' · ') || '—'], + ['Gross volume', pol(r.purchases.volumeWei) + ' POL', usdOf(r.purchases.volumeWei, px) + ' at today\'s rate · $' + (r.purchases.usdCents / 100).toLocaleString() + ' at sale'], + ['Platform (fees + dust + unclaimed)', pol(r.platformWei) + ' POL', usdOf(r.platformWei, px)], + ['Paid to members', pol(r.memberPayoutsWei) + ' POL', usdOf(r.memberPayoutsWei, px)], + ['Net after fixed costs', '$' + Math.round(platUsd - fixed).toLocaleString(), 'fixed ' + Math.round(fixed).toLocaleString() + ' over ' + months.toFixed(1) + ' month(s)'], + ['Pass-ups', r.passedUp.count, r.passedUp.unqualified + ' unqualified · ' + r.passedUp.sendFailed + ' send-failed'] + ].map(t => '
' + esc(String(t[1])) + '
' + esc(t[0]) + '
' + esc(t[2]) + '
').join(''); + $('pnlSplit').innerHTML = 'LinePOLUSD now' + + [['Level 1 (50%)', r.byTier[1]], ['Level 2 (20%)', r.byTier[2]], ['Level 3 (10%)', r.byTier[3]], ['Platform (20% + pass-ups)', r.platformWei]].map(x => '' + x[0] + '' + pol(x[1]) + '' + usdOf(x[1], px) + '').join(''); + const W = r.wallets || {}, B = r.balances || {}; + $('pnlWallets').innerHTML = 'WalletAddressBalance' + + [['Owner / fee A (Tangem)', W.feeA, B.feeA], ['Fee B', W.feeB, B.feeB], ['Engine (gas)', W.engine, B.engine]].filter(x => x[1]).map(x => '' + x[0] + '' + esc(x[1]) + '' + (x[2] == null ? '?' : pol(x[2]) + ' POL') + '').join(''); + $('pnlFixed').value = r.fixedMonthlyUsd || 0; + const b = r.burner || {}; + $('burnerLine').textContent = !b.hasEthers ? 'ethers is not installed in this build.' : !b.keyPresent ? 'No engine key configured (ENGINE_KEY). Burns stay pending until it is set.' : b.mismatch ? 'ENGINE_KEY does not match the contract engine signer. Disabled.' : 'Engine wallet ' + b.address + ' holds ' + pol(b.balanceWei) + ' POL. That is its gas fund, not a cost: one burn uses about 0.003 POL (roughly 48,000 gas), paid by this wallet, never by the member. ' + b.burned + ' burn' + (b.burned === 1 ? '' : 's') + ' since boot' + (b.lastRun ? ' · last check ' + when(b.lastRun) : '') + (b.lastError ? ' · last error: ' + b.lastError : '') + (b.skipped && Object.keys(b.skipped).length ? ' · skipped (needs review): ' + Object.entries(b.skipped).map(([k, v]) => k + ' (' + v + ')').join(', ') : ''); + } + document.querySelectorAll('#pnlPeriods [data-days]').forEach(b => b.addEventListener('click', () => { pnlDays = Number(b.dataset.days); document.querySelectorAll('#pnlPeriods [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadPnl().catch(e => IAP.status(e.message, 'bad')); })); + if ($('pnlFixedSave')) $('pnlFixedSave').addEventListener('click', async () => { try { await api('/api/admin/site', { pnlFixedMonthlyUsd: Number($('pnlFixed').value) || 0 }, 'PATCH'); IAP.status('Saved.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } }); + if ($('burnerRun')) $('burnerRun').addEventListener('click', async () => { try { const r = await api('/api/admin/burner/run', {}); IAP.status('Burner ran: ' + (r.burned || 0) + ' burned.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } }); + async function loadAudit() { + if (!$('audTable')) return; + try { + $('audSub').textContent = 'checking…'; + const a = await api('/api/admin/audit'); + const bad = a.checks.filter(c => !c.ok).length; + $('audSub').textContent = (bad ? bad + ' issue' + (bad === 1 ? '' : 's') : 'all counters reconcile') + ' · checked ' + new Date(a.checkedAt).toLocaleTimeString(); + $('audTable').innerHTML = 'CheckStatusDetail' + a.checks.map(c => '' + esc(c.name) + '' + (c.ok ? 'ok' : '' + c.issues.length + ' issue' + (c.issues.length === 1 ? '' : 's') + '') + '' + esc(c.detail) + (c.issues.length ? '
' + c.issues.map(esc).join('
') : '') + '').join(''); + } catch (e) { $('audSub').textContent = e.message; } + } + if ($('audRun')) $('audRun').addEventListener('click', busy($('audRun'), loadAudit)); + async function loadReports() { + loadAudit(); + const [r, b] = await Promise.all([api('/api/admin/reports'), api('/api/admin/burns')]); + const reps = r.reports || []; + $('repTable').innerHTML = reps.length ? 'WhenCampaignReasonNoteBy' + + reps.map(x => '' + when(x.ts) + '#' + x.campaignId + '' + esc(x.reason) + '' + esc(x.note || '') + '' + esc(x.reporter || 'anon') + '' + + '' + (x.resolved ? 'resolved' : '') + '').join('') + : 'No reports.'; + const burns = b.pending || []; + $('burnTable').innerHTML = burns.length ? 'WhenMemberCreditsRefBurn id' + + burns.map(x => '' + when(x.ts) + '#' + x.memberId + '' + x.amount + '' + esc(x.ref) + '' + esc(x.id) + '').join('') + : 'Nothing pending.'; + } + document.addEventListener('click', async e => { + const b = e.target.closest('[data-resolve]'); if (!b) return; + b.disabled = true; + try { await api('/api/admin/reports/' + b.dataset.resolve + '/resolve', {}); IAP.status('Report resolved.', 'ok'); await Promise.all([loadReports(), loadOverview()]); } + catch (err) { IAP.status(err.message, 'bad'); b.disabled = false; } + }); + + // ── settings: graphical editors (follow-up emails, rates, site settings) ── + let dripSeq = [], ratesObj = {}, siteObj = {}; + let lastFocusedField = null; + document.addEventListener('focusin', e => { if (e.target && (e.target.matches('textarea.ds-body') || e.target.matches('input.ds-subject'))) lastFocusedField = e.target; }); + document.addEventListener('click', e => { + const c = e.target.closest('[data-ph]'); if (!c) return; + const el = lastFocusedField; if (!el) { IAP.status('Click into a subject or body first, then the chip.', 'bad'); return; } + const ph = c.dataset.ph, st = el.selectionStart || 0, en = el.selectionEnd || st; + el.value = el.value.slice(0, st) + ph + el.value.slice(en); + el.focus(); el.selectionStart = el.selectionEnd = st + ph.length; + el.dispatchEvent(new Event('input')); + }); + const whenLabel = h => { h = Number(h) || 0; if (h < 24) return h + ' hour' + (h === 1 ? '' : 's') + ' after sign-up'; const d = h / 24; return (Number.isInteger(d) ? d : d.toFixed(1)) + ' day' + (d === 1 ? '' : 's') + ' after sign-up'; }; + function drawDrip() { + const wrap = $('dripSteps'); + wrap.innerHTML = dripSeq.map((st, i) => '
' + + '
EMAIL ' + (i + 1) + '' + + 'send hours after sign-up (' + esc(whenLabel(st.hours)) + ')' + + '' + + '
' + + '' + + '' + + '
').join('') || '

No emails yet. Add one below.

'; + } + function readDrip() { + return [...document.querySelectorAll('#dripSteps .drip-step')].map(card => ({ + hours: Number(card.querySelector('.ds-hours').value) || 0, + subject: card.querySelector('.ds-subject').value.trim(), + body: card.querySelector('.ds-body').value.trim() + })); + } + $('dripSteps').addEventListener('input', e => { + if (e.target.classList.contains('ds-hours')) { const l = e.target.closest('.ds-when').querySelector('.ds-whenlbl'); if (l) l.textContent = '(' + whenLabel(e.target.value) + ')'; } + }); + $('dripSteps').addEventListener('click', async e => { + const b = e.target.closest('[data-act]'); if (!b) return; + const card = b.closest('.drip-step'), i = Number(card.dataset.i); + dripSeq = readDrip(); + if (b.dataset.act === 'remove') { if (!confirm('Remove email ' + (i + 1) + '?')) return; dripSeq.splice(i, 1); drawDrip(); return; } + if (b.dataset.act === 'up' && i > 0) { [dripSeq[i - 1], dripSeq[i]] = [dripSeq[i], dripSeq[i - 1]]; drawDrip(); return; } + if (b.dataset.act === 'down' && i < dripSeq.length - 1) { [dripSeq[i + 1], dripSeq[i]] = [dripSeq[i], dripSeq[i + 1]]; drawDrip(); return; } + if (b.dataset.act === 'test') { + b.disabled = true; + try { await saveDrip(); await api('/api/admin/drip/test', { step: i }); IAP.status('Email ' + (i + 1) + ' sent to your inbox.', 'ok'); } + catch (err) { IAP.status(err.message, 'bad'); } + b.disabled = false; + } + }); + async function saveDrip() { + $('dripErr').hidden = true; + const seq = readDrip(); + const r = await api('/api/admin/drip', { sequence: seq }, 'PATCH').catch(err => { $('dripErr').textContent = err.message; $('dripErr').hidden = false; throw err; }); + dripSeq = r.sequence; drawDrip(); + return r; + } + $('dripSave').addEventListener('click', busy($('dripSave'), async () => { await saveDrip(); IAP.status('Sequence saved.', 'ok'); await loadSettings(); })); + $('dripAdd').addEventListener('click', () => { + dripSeq = readDrip(); + const last = dripSeq[dripSeq.length - 1]; + dripSeq.push({ hours: last ? Number(last.hours) + 48 : 24, subject: '', body: '\n\nMarty\n\n{{footer}}' }); + drawDrip(); + const cards = document.querySelectorAll('#dripSteps .drip-step'); const c = cards[cards.length - 1]; if (c) { c.scrollIntoView({ behavior: 'smooth', block: 'center' }); c.querySelector('.ds-subject').focus(); } + }); + $('dripReset').addEventListener('click', busy($('dripReset'), async () => { + if (!confirm('Replace the saved sequence with the built-in defaults?')) return; + const r = await api('/api/admin/drip', { reset: true }, 'PATCH'); + dripSeq = r.sequence; drawDrip(); IAP.status('Defaults restored.', 'ok'); await loadSettings(); + })); + + // rates: labels + hints for the known keys; anything unknown still gets a plain field + const RATE_META = { + bannerBatch: ['Banner: views per batch', 'impressions counted before a banner campaign is charged'], + bannerCreditsPerBatch: ['Banner: credits per batch', 'charged to the advertiser per batch'], + textBatch: ['Text ad: views per batch', ''], textCreditsPerBatch: ['Text ad: credits per batch', ''], + loginCreditsPerDay: ['Login ad: credits per day', 'flat daily charge while active'], + loginDwellSeconds: ['Login ad: seconds shown', 'full-screen interstitial after sign-in'], + burnBatchMin: ['On-chain burn batch (credits)', 'accrued spend is burned once it reaches this'], + welcomeCredits: ['Welcome credits', 'granted after the welcome tour'], + dailyViewTarget: ['Daily view set (ads)', 'ads a member views for the daily claim'], + dailyClaimCredits: ['Daily claim (credits)', 'paid when the set is complete'], + viewDwellSeconds: ['Ad view: seconds per ad', 'the countdown; server-enforced'], + soloCostPerRecipient: ['Solo ad: credits per recipient', ''], soloMinRecipients: ['Solo ad: minimum recipients', ''], + soloReadCredits: ['Solo ad: reader reward (credits)', ''], soloReadCapPerDay: ['Solo ad: rewarded reads per day', ''], soloReadDwellSeconds: ['Solo ad: seconds to read', ''], + videoWatchCapPerDay: ['Video: rewarded watches per day', ''], + featuredPerDay: ['Featured link: credits per day', ''], featuredSlotsPerDay: ['Featured link: slots per day', ''], featuredWindowDays: ['Featured link: booking window (days)', ''], + featuredDurations: ['Featured link: durations offered (days)', 'comma-separated'], + visitCostPerVisit: ['Verified visit: credits per visit', ''], visitMinPack: ['Verified visit: smallest pack', ''], visitReward: ['Verified visit: viewer reward (credits)', ''], visitDwellSeconds: ['Verified visit: seconds on site', ''], visitCapPerDay: ['Verified visit: rewarded visits per day', ''], + videoTiers: ['Video ad tiers', 'watch length → advertiser cost → viewer reward'], + milestoneBonus: ['Milestone bonuses (credits)', 'one-time, when a member reaches each step'] + }; + const humanize = k => k.replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase()); + function drawRates() { + const wrap = $('ratesForm'); const html = []; + for (const [k, v] of Object.entries(ratesObj)) { + const [label, hint] = RATE_META[k] || [humanize(k), '']; + if (typeof v === 'number') html.push('
' + (hint ? '' + esc(hint) + '' : '') + '
'); + else if (typeof v === 'boolean') html.push('
'); + else if (Array.isArray(v) && v.every(x => typeof x === 'number')) html.push('
' + (hint ? '' + esc(hint) + '' : '') + '
'); + else if (Array.isArray(v) && v.every(x => x && typeof x === 'object')) { + const cols = [...new Set(v.flatMap(x => Object.keys(x)))]; + html.push('
' + (hint ? '' + esc(hint) + '' : '') + + '' + cols.map(c => '').join('') + '' + + v.map((row, i) => '' + cols.map(c => '').join('') + '').join('') + '
' + esc(c) + '
'); + } else if (v && typeof v === 'object') { + html.push('
' + (hint ? '' + esc(hint) + '' : '') + '
' + + Object.entries(v).map(([sk, sv]) => '').join('') + '
'); + } else html.push('
'); + } + wrap.innerHTML = html.join(''); + } + function readRates() { + const out = {}; + document.querySelectorAll('#ratesForm [data-rk]').forEach(el => { + const k = el.dataset.rk, kind = el.dataset.kind; + if (kind === 'numlist') out[k] = el.value.split(/[\s,]+/).filter(Boolean).map(Number).filter(n => !isNaN(n)); + else if (kind === 'table') out[k] = [...el.querySelectorAll('tr')].slice(1).map(tr => { const o = {}; tr.querySelectorAll('input[data-col]').forEach(i => { o[i.dataset.col] = Number(i.value); }); return o; }); + else if (kind === 'object') { const o = {}; el.querySelectorAll('input[data-sub]').forEach(i => { o[i.dataset.sub] = Number(i.value); }); out[k] = o; } + else if (el.type === 'checkbox') out[k] = !!el.checked; + else if (el.type === 'number') out[k] = Number(el.value); + else out[k] = el.value; + }); + return out; + } + $('ratesSave').addEventListener('click', busy($('ratesSave'), async () => { + $('ratesErr').hidden = true; + try { const r = await api('/api/admin/rates', readRates(), 'PATCH'); ratesObj = r.rates || readRates(); drawRates(); IAP.status('Rates saved.', 'ok'); } + catch (e) { $('ratesErr').textContent = e.message; $('ratesErr').hidden = false; } + })); + + // site settings: key / value rows; booleans as checkboxes, numbers stay numbers + const SITE_META = { noPayoutIds: 'No-payout positions (member #s, comma): linkage only, no buys from them, no joins routed under them', siteName: 'Site name', tagline: 'Tagline', rehearsal: 'Testnet rehearsal banner', defaultSponsorId: 'Fallback sponsor (member #)', walletConnectProjectId: 'WalletConnect project id', moonpayPublicKey: 'MoonPay public key', telegramBotToken: 'Telegram proof feed: bot token', telegramChatId: 'Telegram proof feed: chat id', telegramTopicId: 'Telegram proof feed: topic id (optional)', telegramEvents: 'Telegram proof feed: events (payouts | payouts+purchases | all)', telegramCtaUrl: 'Telegram proof feed: join link under each post', aiCreditsPerGen: 'AI Copy Engine: credits per generation after the free allowance', aiFreeSurge: 'AI Copy Engine: free generations a month at Surge', aiFreeCircuit: 'AI Copy Engine: free generations a month at Circuit', aiFreeNexus: 'AI Copy Engine: free generations a month at Nexus', snapshotEnabled: 'Daily growth snapshot to Telegram (1/0)', snapshotHourUtc: 'Daily growth snapshot: hour (UTC; 14 = 9 AM Central)', snapshotTargets: 'Daily growth snapshot: targets (feed = proof channel, echo = shared payments topic; comma list)', pipelineMode: 'Pipeline board: off (coming soon card) | preview (admin account only) | on (everyone)', pipelineEta: 'Pipeline: opening date shown on the coming-soon card (e.g. Sep 28)', memberWeeklyEmail: 'Weekly member email to everyone active (1) or only sponsors with a line (0)', leaderboardWeeklyPrize: 'Leaderboard: weekly prize text (optional; blank shows the credit ladder)', leaderboardMonthlyPrize: 'Leaderboard: monthly prize text (optional)', leaderboardWeeklyCredits: 'Leaderboard: weekly credits for 1st,2nd,3rd… (e.g. 1000,500,250; blank = none)', leaderboardMonthlyCredits: 'Leaderboard: monthly credits for 1st,2nd,3rd… (e.g. 5000,2500,1000)', leaderboardAnnounceGeneral: 'Leaderboard: announce winners in the main group too (1/0)', telegramEchoChatId: 'Telegram echo (shared payments topic): chat id', telegramEchoTopicId: 'Telegram echo: topic id', telegramEchoEvents: 'Telegram echo: events (payouts | payouts+purchases | all)', legacyCreditsAdvertiser: 'Legacy welcome credits: former advertisers', legacyCreditsEarner: 'Legacy welcome credits: former earners', pnlFixedMonthlyUsd: 'P&L: fixed monthly cost (USD)' }; + function drawSite() { + const wrap = $('siteForm'); + wrap.innerHTML = Object.entries(siteObj).map(([k, v]) => '
' + esc(SITE_META[k] || humanize(k)) + '' + + (typeof v === 'boolean' ? '' + : typeof v === 'number' ? '' + : '') + + '
').join('') || '

No settings saved yet.

'; + } + function readSite() { + const out = {}; + document.querySelectorAll('#siteForm [data-sk]').forEach(el => { + const k = el.dataset.sk; + if (el.type === 'checkbox') out[k] = !!el.checked; + else if (el.type === 'number') out[k] = Number(el.value); + else { const v = el.value; if (/^[\[{]/.test(v)) { try { out[k] = JSON.parse(v); return; } catch (e) {} } out[k] = v; } + }); + return out; + } + $('siteForm').addEventListener('click', e => { + const b = e.target.closest('[data-sdel]'); if (!b) return; + siteObj = readSite(); siteObj[b.dataset.sdel] = ''; drawSite(); + }); + $('siteAddKey').addEventListener('click', () => { + const k = $('siteNewKey').value.trim(); if (!/^[A-Za-z][A-Za-z0-9_]{0,40}$/.test(k)) { IAP.status('Setting names are letters and numbers, no spaces.', 'bad'); return; } + siteObj = readSite(); if (!(k in siteObj)) siteObj[k] = ''; $('siteNewKey').value = ''; drawSite(); + const el = document.querySelector('#siteForm [data-sk="' + k + '"]'); if (el) el.focus(); + }); + $('siteSave').addEventListener('click', busy($('siteSave'), async () => { + $('siteErr').hidden = true; + try { const r = await api('/api/admin/site', readSite(), 'PATCH'); siteObj = r.site || readSite(); drawSite(); IAP.status('Site settings saved.', 'ok'); } + catch (e) { $('siteErr').textContent = e.message; $('siteErr').hidden = false; } + })); + + async function loadSettings() { + const [r, s, d] = await Promise.all([api('/api/admin/rates'), api('/api/admin/site'), api('/api/admin/drip')]); + ratesObj = r.rates || {}; drawRates(); + siteObj = s.site || {}; drawSite(); + dripSeq = d.sequence || []; drawDrip(); + const st = d.stats || {}; + $('dripSub').textContent = (st.active || 0) + ' in flight · ' + (st.done || 0) + ' finished · ' + (st.unsubscribed || 0) + ' unsubscribed' + (d.mailReady ? '' : ' · NO MAIL KEY: nothing sends'); + } + + render(); +})(); diff --git a/public/assets/blog-page.js b/public/assets/blog-page.js new file mode 100644 index 0000000..711fc16 --- /dev/null +++ b/public/assets/blog-page.js @@ -0,0 +1,2 @@ +// public blog pages: the shared nav (with wallet status) and footer, nothing else +(function () { try { IAP.renderNav(location.pathname.indexOf('/leaderboard') === 0 ? 'leaderboard' : 'blog'); } catch (e) {} })(); diff --git a/public/assets/chat.js b/public/assets/chat.js new file mode 100644 index 0000000..5b69934 --- /dev/null +++ b/public/assets/chat.js @@ -0,0 +1,58 @@ +// 24/7 assistant widget: floating bubble, slide-up panel, /api/chat. +(function () { + const root = document.createElement('div'); + root.id = 'iapChat'; + root.innerHTML = '' + + ''; + document.body.appendChild(root); + + const $ = id => document.getElementById(id); + const msgs = $('iapChatMsgs'); + const input = $('iapChatIn'); + let history = []; + + const add = (text, who) => { + const d = document.createElement('div'); + d.className = 'ch-m ' + who; + // linkify plain URLs + d.innerHTML = String(text).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])) + .replace(/(https?:\/\/[^\s]+)/g, '$1'); + msgs.appendChild(d); + msgs.scrollTop = msgs.scrollHeight; + return d; + }; + async function send() { + const q = input.value.trim(); + if (!q) return; + input.value = ''; + add(q, 'me'); + const wait = add('…', 'bot'); + try { + const r = await (await fetch('/api/chat', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: q, history: history.slice(-4).join(' | ') }) })).json(); + wait.remove(); + add(r.reply || r.error || 'No answer came back. Try again.', 'bot'); + history.push('Q: ' + q, 'A: ' + (r.reply || '')); + } catch (e) { + wait.remove(); + add('Connection hiccup. Try that again.', 'bot'); + } + } + $('iapChatBtn').addEventListener('click', () => { + const p = $('iapChatPanel'); + p.hidden = !p.hidden; + if (!p.hidden) input.focus(); + }); + $('iapChatClose').addEventListener('click', () => { $('iapChatPanel').hidden = true; }); + $('iapChatSend').addEventListener('click', send); + input.addEventListener('keydown', e => { if (e.key === 'Enter') send(); }); +})(); diff --git a/public/assets/common.js b/public/assets/common.js new file mode 100644 index 0000000..88b68ac --- /dev/null +++ b/public/assets/common.js @@ -0,0 +1,248 @@ +// Shared page runtime: site config, nav, formatting. Zero dependencies. +window.IAP = (function () { + let config = null; + const $ = id => document.getElementById(id); + + async function getConfig() { + if (!config) config = await (await fetch('/api/config')).json(); + return config; + } + // POL amounts display with two decimals (rounded half-up), e.g. 523.39 + function fmtPol(wei) { + const cents = (BigInt(wei) + 5000000000000000n) / 10000000000000000n; // wei -> hundredths of a POL + const s = cents.toString().padStart(3, '0'); + return s.slice(0, -2) + '.' + s.slice(-2); + } + const fmtUsd = cents => '$' + (cents / 100).toFixed(2); + + function status(msg, cls) { + let el = $('status'); + if (!el) { el = document.createElement('div'); el.id = 'status'; document.body.appendChild(el); } + el.textContent = msg; el.className = cls || ''; el.hidden = false; + clearTimeout(status._t); + if (cls === 'ok') status._t = setTimeout(() => { el.hidden = true; }, 6000); + } + + async function renderNav(active) { + const c = await getConfig(); + const nav = document.createElement('nav'); + nav.innerHTML = '
' + + '' + + '' + + 'How it works' + + 'Ad packages' + + 'Live ledger' + + 'Leaderboard' + + 'The contract' + + 'Members' + + '
'; + document.body.prepend(nav); + if (c.rehearsal) { + const b = document.createElement('div'); + b.className = 'rehearsal'; + b.innerHTML = 'Testnet rehearsal: running on ' + c.chainName + '. Purchases use valueless test POL while we prove every payout in public.'; + document.body.prepend(b); + } + const a = nav.querySelector('[data-p="' + active + '"]'); + if (a) a.className = 'active'; + refreshNavWallet(); + renderFooter(); + } + function renderFooter() { + if (document.getElementById('iapFooter')) return; + const f = document.createElement('footer'); f.id = 'iapFooter'; + f.style.cssText = 'border-top:1px solid var(--line);margin-top:48px;padding:26px 22px;text-align:center;color:var(--muted);font-size:13px'; + f.innerHTML = '
© ' + new Date().getFullYear() + ' LinkSpin
' + + ''; + document.body.appendChild(f); + } + async function refreshNavWallet() { + try { + const me = await (await fetch('/api/me')).json(); + const el = $('navWallet'); + if (!el) return; + if (me.signedIn) { + // identity order: username, then email, then wallet + const who = me.username + ? '@' + String(me.username).replace(/[&<>]/g, '') + '' + : (me.email ? String(me.email).replace(/[&<>]/g, '') + : (me.address ? '' + me.address.slice(0, 6) + '…' + me.address.slice(-4) + '' : 'signed in')); + el.innerHTML = (me.memberId ? 'member #' + me.memberId + ' ' : '') + who; + } else { + el.innerHTML = 'Sign in'; + } + return me; + } catch (e) { return null; } + } + function describeEvent(ev, c) { + const pol = w => fmtPol(w) + ' POL'; + // real people, not numbers: use usernames when the site knows them + const nm = id => (ev.names && ev.names[id]) + ? String(ev.names[id]).replace(/[&<>]/g, '') + : 'member #' + id; + switch (ev.type) { + case 'Purchase': return '🧾 ' + nm(ev.buyerId) + ' bought package #' + ev.productId + + ' (' + fmtUsd(ev.priceCents) + ') for ' + pol(ev.paidWei) + ' → +' + ev.creditAmount.toLocaleString() + ' credits'; + case 'TierPaid': return '💸 level ' + ev.tier + ' payout → ' + nm(ev.recipientId) + ': ' + pol(ev.amountWei) + + (ev.hops ? ' (passed up ' + ev.hops + ')' : ''); + case 'PassedUp': return '↷ level ' + ev.tier + ' passed over ' + nm(ev.skippedId) + ' (' + ev.reason + ')'; + case 'AdminPaid': return '🏛 platform fee settled: ' + pol(ev.amountWei); + case 'BuyerCounted': return '⭐ ' + nm(ev.sponsorId) + ' now has ' + ev.newCount + ' qualifying buyer(s)'; + case 'MemberActivated': return '👤 ' + nm(ev.id) + ' activated a payout wallet'; + case 'AwardPaid': return '🎁 award: ' + pol(ev.amountWei) + ' → ' + nm(ev.toId); + case 'CreditsConsumed': return '📣 ' + nm(ev.memberId) + ' ran ads: −' + ev.amount.toLocaleString() + ' credits'; + case 'PriceCached': return '🔮 oracle price refreshed'; + case 'FallbackPriceUsed': return '🔮 cached price bridged an oracle gap'; + default: return '· ' + ev.type; + } + } + function feedRow(ev, c) { + const div = document.createElement('div'); + div.className = 'row t-' + ev.type; + const when = ev.ts ? new Date(ev.ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''; + div.innerHTML = (when ? '' + when + '' : '') + '' + describeEvent(ev, c) + '' + + (c.explorer + ? 'verify ↗' + : 'verify ↗'); // built-in viewer when the chain has no public explorer + return div; + } + // Render one served ad into #. Silent if no inventory. + // report an ad (auto-approved ads need a member-facing flag → admin notified) + function reportAd(campaignId) { + if (!campaignId) return; + const reason = (prompt('Report this ad. Reason: broken, inappropriate, spam, scam, or other', 'broken') || '').trim().toLowerCase(); + if (!reason) return; + const note = prompt('Anything to add? (optional)') || ''; + fetch('/api/report-ad', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ campaignId, reason, note }) }) + .then(() => status('Thanks — this ad was reported to the admin for review.', 'ok')) + .catch(() => status('Could not send the report. Try again.', 'bad')); + } + const reportTag = ad => ' ⚠ report'; + function wireReport(el) { + const rl = el.querySelector('.ad-report'); + if (rl) rl.addEventListener('click', e => { e.preventDefault(); reportAd(Number(rl.dataset.cid)); }); + } + async function adSlot(type, elId, opts) { + try { + const q = '/api/ads/slot?type=' + type + (opts && opts.width ? '&w=' + opts.width + '&h=' + opts.height : ''); + const { ad } = await (await fetch(q)).json(); + const el = $(elId); + if (!ad || !el) return; + el.hidden = false; + if (ad.imageUrl) { + el.style.textAlign = 'center'; + el.innerHTML = '' + + 'advertisement' + + '
member ad' + reportTag(ad) + '
'; + } else { + el.innerHTML = '' + ad.title + '' + + (ad.body ? ' · ' + ad.body : '') + ' member ad' + reportTag(ad) + ''; + } + wireReport(el); + el.hidden = false; + } catch (e) {} + } + // ── sign-up code request with the invisible guard fields (form age + honeypot) + // and the icon check the server asks for only after an IP trips a limit ── + const FORM_TS = Date.now(); + function iconCheck(host, ch, note) { + return new Promise(resolve => { + host.hidden = false; + host.innerHTML = '
' + (note ? esc(note) + ' ' : '') + 'Tap the ' + esc(ch.prompt) + '.
' + + '
' + ch.options.map(o => '').join('') + '
'; + host.querySelectorAll('.ic-btn').forEach(b => b.addEventListener('click', () => { host.innerHTML = ''; host.hidden = true; resolve(b.textContent); }, { once: true })); + }); + } + // honeypot fields (join + sign-in): read-only until a trusted focus, so browser autofill and + // password managers leave them alone; a value that appeared without a trusted event is ignored + function armHoneypots() { + document.querySelectorAll('.hp-field').forEach(el => { + if (el.dataset.armed) return; el.dataset.armed = '1'; el.readOnly = true; + const touch = e => { if (e.isTrusted) { el.readOnly = false; el.dataset.touched = '1'; } }; + el.addEventListener('focus', touch); el.addEventListener('input', touch); el.addEventListener('keydown', touch); + }); + } + armHoneypots(); document.addEventListener('DOMContentLoaded', armHoneypots); + const hpValue = el => (el && el.dataset.touched === '1') ? (el.value || '') : ''; + async function requestCode(email, opts) { + const o = opts || {}; + let pick = null; + for (let i = 0; i < 4; i++) { + const r = await (await fetch('/api/auth/email/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, fts: FORM_TS, hp_field_x9: hpValue(o.honeypot), pick }) })).json(); + if (r.challenge && o.host) { pick = await iconCheck(o.host, r.challenge, r.error); continue; } + if (r.error) throw new Error(r.error); + return r; + } + throw new Error('Could not verify. Refresh the page and try again.'); + } + const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + // ── founding-week checklist, read from the live account. Shared by /launch and + // the dashboard mark. Two items are the member's own call and persist locally. + const LAUNCH_KEY = 'iap.launch.manual'; + const manualSet = () => { try { return new Set(JSON.parse(localStorage.getItem(LAUNCH_KEY) || '[]')); } catch (e) { return new Set(); } }; + function launchToggle(key) { const s = manualSet(); if (s.has(key)) s.delete(key); else s.add(key); try { localStorage.setItem(LAUNCH_KEY, JSON.stringify([...s])); } catch (e) {} } + function launchChecks(me) { + const m = me || {}, man = manualSet(); + const bc = Number(m.buyerCount || 0), refs = (m.referrals || []).length; + return [ + { key: 'username', title: 'Pick your username', done: !!m.username, href: '/my#profile', cta: 'Profile', + how: 'Profile tab. It becomes your invite link and your public page, and it is permanent.', + why: 'Every link, banner and video you hand out this week carries it. Change it later and the links you already sent die.' }, + { key: 'wallet', title: 'Link your wallet', done: !!m.address, href: '/my#wallet', cta: 'Wallet', + how: 'Wallet tab, Connect, sign the free message. MetaMask recommended. Never held crypto? The wallet guide in Training walks through buying POL with a card.', + why: 'Payouts go to this address. No wallet, nowhere to pay you.' }, + { key: 'payouts', title: 'Switch on payouts', done: !!m.memberId, href: '/my#wallet', cta: 'Wallet', + how: 'Wallet tab, one small transaction. It registers your address with the contract.', + why: 'The contract binds each buyer to their sponsor at their first purchase. If payouts are off when your first person buys, that commission is not yours.' }, + { key: 'level2', title: 'Qualify: open level 2', done: bc >= 2, href: '/my#buy', cta: 'Buy packages', + how: 'Two of your people buy a $20 or more package. Or use Qualified Start: add two positions from extra wallets in your own MetaMask and buy a $20 package from each (about $23 of POL in each wallet).', + why: 'Until you have two qualifying buyers, every level 2 payment from your team climbs past you.', + note: 'Qualifying buyers so far: ' + bc + ' of 2.' }, + { key: 'level3', title: 'The leader play: open all three levels', done: bc >= 5, href: '/my#buy', cta: 'Qualified Start', + how: 'Five qualifying buyers, real or Qualified Start, up to five linked positions. Once qualified, buy from your main wallet so your sponsor is paid in full.', + why: 'A leader whose team goes three deep this week collects level 3 from day one instead of watching those 10% payments pass upward. Optional for members, the play for leaders.', + note: 'Qualifying buyers so far: ' + bc + ' of 5.' }, + { key: 'banner', title: 'Upload your line banner', done: !!m.lineBannerUrl, href: '/my#profile', cta: 'Profile', + how: 'Profile tab, line banner. It shows on the welcome tour to everyone in your next three levels.', + why: 'Your first advertising to your own team, free, and it is live the moment they join.' }, + { key: 'links', title: 'Copy your links and pick a play', done: man.has('links'), manual: true, + how: 'Promo tools, Your links: the invite link and the five angle links, plus the matching hook videos. Then read the plays page and choose one.', + why: 'On launch day you send links, not explanations. Having them ready is the whole difference between a launch and a scramble.' }, + { key: 'two', title: 'Place your first two', done: refs >= 2, href: '/my#line', cta: 'My line', + how: 'Two people you have actually talked to, joined through your link, walked through items 1 to 3 on their own accounts.', + why: 'Your first two are the shape of your whole line. Choose them, do not wait for them.', + note: 'Joined through you so far: ' + refs + '.' } + ]; + } + // In-page dialogs instead of window.prompt / confirm. Mobile Safari shows a red "Suppress dialogs" + // option on the second native pop-up in a row and, once tapped, swallows every later prompt on the + // site until reload. ask() resolves the typed value (null on cancel); confirmBox() resolves true/false. + function dialog(o) { + return new Promise(resolve => { + const esc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const back = document.createElement('div'); back.className = 'modal-back'; back.style.zIndex = '200'; + const field = o.type === 'none' ? '' : o.type === 'textarea' + ? '' + : ''; + back.innerHTML = ''; + document.body.appendChild(back); + const inp = back.querySelector('#dlgInput'); + const done = v => { document.removeEventListener('keydown', onKey); back.remove(); resolve(v); }; + const okv = () => done(o.type === 'none' ? true : (inp ? inp.value : '')); + const onKey = e => { if (e.key === 'Escape') { e.preventDefault(); done(o.type === 'none' ? false : null); } else if (e.key === 'Enter' && o.type !== 'textarea') { e.preventDefault(); okv(); } }; + document.addEventListener('keydown', onKey); + back.querySelector('#dlgOk').addEventListener('click', okv); + back.querySelector('#dlgCancel').addEventListener('click', () => done(o.type === 'none' ? false : null)); + back.addEventListener('click', e => { if (e.target === back) done(o.type === 'none' ? false : null); }); + setTimeout(() => { if (inp) { inp.focus(); if (inp.select && o.type !== 'textarea') inp.select(); } else back.querySelector('#dlgOk').focus(); }, 30); + }); + } + function ask(o) { return dialog(Object.assign({ type: 'text', value: '', placeholder: '' }, o || {})); } + function confirmBox(text, o) { return dialog(Object.assign({ type: 'none', text, ok: 'Yes', cancel: 'No' }, o || {})); } + return { getConfig, fmtPol, fmtUsd, status, renderNav, refreshNavWallet, describeEvent, feedRow, adSlot, reportAd, requestCode, launchChecks, launchToggle, ask, confirmBox, $ }; +})(); diff --git a/public/assets/contract.js b/public/assets/contract.js new file mode 100644 index 0000000..c984a47 --- /dev/null +++ b/public/assets/contract.js @@ -0,0 +1,21 @@ +// Contract page: inject live address, chain, explorer + verified-source links. +(async function () { + await IAP.renderNav('contract'); + const c = await IAP.getConfig(); + const $ = IAP.$; + $('cAddr').textContent = c.contract.slice(0, 10) + '…' + c.contract.slice(-6); + $('cChain').textContent = c.chainName; + $('mockAddr').textContent = c.contract.slice(0, 18) + '…'; + if (c.explorer) { + $('lnkExplorer').href = c.explorer + '/address/' + c.contract + '#code'; + const src = 'https://repo.sourcify.dev/contracts/full_match/' + c.chainId + '/' + c.contract + '/'; + $('lnkSource').href = src; + $('lnkSource2').href = src; + } else { + // fallback if explorer unset: point straight at the Sourcify-verified source + const src = 'https://repo.sourcify.dev/contracts/full_match/137/0xBE1ECA72AFF47d13D8E907e523F55eB9e2d365E0/'; + $('lnkExplorer').href = src; + $('lnkSource').href = src; + $('lnkSource2').href = src; + } +})(); diff --git a/public/assets/home.js b/public/assets/home.js new file mode 100644 index 0000000..4f13375 --- /dev/null +++ b/public/assets/home.js @@ -0,0 +1,149 @@ +// Landing page: live ladder, buy buttons, sponsor attribution line. +(async function () { + await IAP.renderNav('home'); + const c = await IAP.getConfig(); + IAP.$('contractLink').href = c.explorer + '/address/' + c.contract; + + const sp = await (await fetch('/api/sponsor')).json(); + if (sp.invited) { + const el = IAP.$('sponsorLine'); + el.hidden = false; + el.textContent = (sp.sponsorId ? 'You were invited by member #' + sp.sponsorId + '.' : 'You arrived through a member’s invite.') + + ' Your purchases pay their team, and your own link will do the same for you.'; + } + + async function loadLadder() { + const { products } = await (await fetch('/api/catalog')).json(); + const wrap = document.getElementById('tiles'); + wrap.innerHTML = ''; + const NAMES = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' }; + for (const p of products) { + const bonus = p.creditAmount - p.priceCents; // credits above 1cr/cent = bulk bonus + const div = document.createElement('div'); + div.className = 'tile' + (p.priceCents === 5000 ? ' hot' : ''); + div.innerHTML = '
' + (NAMES[p.id] || 'Package ' + p.id) + '
' + + '
$' + Math.round(p.priceCents / 100) + '
' + + '
' + p.creditAmount.toLocaleString() + ' credits
' + + '
' + (bonus > 0 ? '+' + bonus.toLocaleString() + ' bonus credits' : ' ') + '
' + + '
' + (p.costWei ? IAP.fmtPol(p.costWei) + ' POL right now' : 'paused') + '
' + + ''; + wrap.appendChild(div); + } + wrap.querySelectorAll('button[data-id]').forEach(b => b.addEventListener('click', () => buyPack(b))); + } + async function buyPack(btn) { + try { + btn.disabled = true; + // email members get their wallet linked to the account at buy time + const me = await (await fetch('/api/me')).json(); + if (me.signedIn && me.email && !me.address) { + IAP.status('First, a free signature links your wallet to your account…'); + await IAPWallet.signIn(); + } + IAP.status('Confirm the purchase in your wallet…'); + // resolve the sponsor at buy time: a code referrer who activated since + // page load still gets locked in + const spNow = await (await fetch('/api/sponsor')).json(); + const r = await IAPWallet.buy(Number(btn.dataset.id), spNow.sponsorId || 0, btn.dataset.cost); + if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.'); + IAP.status('Purchase settled on-chain. Credits are yours, payouts delivered. Watch it on the ledger.', 'ok'); + IAP.refreshNavWallet(); + } catch (e) { + IAP.status('Purchase failed: ' + (e.message || e), 'bad'); + } finally { btn.disabled = false; } + } + async function loadStats() { + try { + const s = await (await fetch('/api/stats')).json(); + IAP.$('stMembers').textContent = (s.onchainMembers || 0).toLocaleString(); + IAP.$('stPurchases').textContent = (s.purchases || 0).toLocaleString(); + IAP.$('stPaid').textContent = IAP.fmtPol(s.paidInWei || '0'); + IAP.$('stPayouts').textContent = (s.payouts || 0).toLocaleString(); + } catch (e) {} + } + async function loadTicker() { + try { + const { events } = await (await fetch('/api/feed?n=30')).json(); + if (!events.length) return; + const inner = IAP.$('tickerInner'); + inner.innerHTML = events.map(ev => '' + IAP.describeEvent(ev, c) + '').join(''); + IAP.$('ticker').hidden = false; + // readable pace (Marty, 2026-09-12): about 70 px per second no matter how much text is loaded, + // instead of a fixed 42 s for the whole strip; pause while a finger or pointer rests on it + const wrap = IAP.$('ticker'); + const secs = Math.max(30, Math.round((inner.scrollWidth + wrap.clientWidth) / 70)); + inner.style.animationDuration = secs + 's'; + const pause = on => { inner.style.animationPlayState = on ? 'paused' : 'running'; }; + wrap.addEventListener('mouseenter', () => pause(true)); wrap.addEventListener('mouseleave', () => pause(false)); + wrap.addEventListener('touchstart', () => pause(true), { passive: true }); wrap.addEventListener('touchend', () => pause(false), { passive: true }); + } catch (e) {} + } + // level cycler: chips + generation highlighting + auto-advance + const LVL = { + 1: { pct: '50%', desc: 'Activate with the $20 starter package and switch on payouts from your wallet. From then on your direct referrals each pay you 50 percent of every package they ever buy, in POL, straight to your wallet. Until you activate, you earn ad credits, not POL.' }, + 2: { pct: '20%', desc: 'Bring 2 buyers of $20 or more and level 2 unlocks: 20 percent of every package your referrals’ referrals buy, on every purchase, forever.' }, + 3: { pct: '10%', desc: 'At 5 qualifying buyers, level 3 opens the third generation: 10 percent of everything they buy. Eight positions deep in this picture, and it keeps growing.' } + }; + const viz = document.getElementById('genViz'); + if (viz) { + const chips = [...document.querySelectorAll('.chips [data-lvl]')]; + const setLvl = n => { + viz.dataset.lvl = n; + document.getElementById('vizPct').textContent = LVL[n].pct; + document.getElementById('lvlDesc').textContent = LVL[n].desc; + chips.forEach(ch => ch.classList.toggle('on', ch.dataset.lvl === String(n))); + }; + let cur = 1; + let auto = null; + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) { + auto = setInterval(() => { cur = cur % 3 + 1; setLvl(cur); }, 4200); + } + chips.forEach(ch => ch.addEventListener('click', () => { + if (auto) { clearInterval(auto); auto = null; } // a click takes the wheel + cur = Number(ch.dataset.lvl); + setLvl(cur); + })); + } + + // what-if calculator: pure arithmetic on the locked constants + const dc = document.getElementById('dcDirects'); + if (dc) { + const $id = x => document.getElementById(x); + const usd = n => '$' + n.toLocaleString(undefined, { maximumFractionDigits: 2 }); + const recalc = () => { + const d = Number($id('dcDirects').value); + const p = Number($id('dcPkg').value); + const r = Number($id('dcSpread').value); + $id('dcDirectsV').textContent = d; + $id('dcSpreadV').textContent = r; + const qualifies = p >= 20; // sub-$20 packages never count toward qualification + const l2open = qualifies && d >= 2; + const l3open = qualifies && d >= 5; + const g2 = d * r, g3 = g2 * r; + const e1 = d * p * 0.5; + const e2 = l2open ? g2 * p * 0.2 : 0; + const e3 = l3open ? g3 * p * 0.1 : 0; + $id('dcN1').textContent = d; $id('dcN2').textContent = g2; $id('dcN3').textContent = g3; + $id('dcE1').textContent = usd(e1); + $id('dcE2').textContent = l2open ? usd(e2) : 'passes up'; + $id('dcE3').textContent = l3open ? usd(e3) : 'passes up'; + $id('dcTotal').textContent = usd(e1 + e2 + e3); + const setB = (el, open, need) => { el.textContent = open ? 'open' : 'locked: ' + need; el.className = 'badge' + (open ? '' : ' amber'); }; + setB($id('dcB1'), true, ''); + setB($id('dcB2'), l2open, qualifies ? (2 - d) + ' more buyer(s)' : 'needs $20+ buyers'); + setB($id('dcB3'), l3open, qualifies ? (5 - d) + ' more buyer(s)' : 'needs $20+ buyers'); + $id('dcQualNote').textContent = qualifies + ? 'Buyers of $20 or more count toward your qualification. 2 unlock level 2, 5 unlock level 3.' + : 'Heads up: $5 packages pay your level 1 but do not qualify buyers, so levels 2 and 3 stay locked in this scenario.'; + }; + ['dcDirects', 'dcPkg', 'dcSpread'].forEach(x => $id(x).addEventListener('input', recalc)); + recalc(); + } + + IAP.adSlot('banner', 'adSlotHome'); + loadLadder(); + loadStats(); + loadTicker(); + setInterval(loadStats, 60000); +})(); diff --git a/public/assets/join.js b/public/assets/join.js new file mode 100644 index 0000000..8e62f65 --- /dev/null +++ b/public/assets/join.js @@ -0,0 +1,112 @@ +// Invite / lead-capture page: /join/[?v=] +// Email first (code sign-in creates the account), wallet later inside the +// member area. The angle only changes the hook copy; the sponsor cookie was +// set by the server when this page was served. +(function () { + const $ = id => document.getElementById(id); + const LEG = (brand, seg) => seg === 'adv' + ? { eyebrow: 'For former ' + brand + ' advertisers', h: 'Your next ad budget pays you back.', + lead: brand + ' is closed. The people who bought ads there are exactly who LinkSpin was built for: real ad packages from $5, seven formats, and every package in your line paid out by a verified contract on Polygon in the same transaction.', + points: ['Welcome-back credits land the moment your account exists: enough to run a real banner or text campaign today, on us.', 'Seven formats: banners, text ads, login ads, solo ads to member inboxes, video, featured links and verified visits. Views are timed on the server, so a real person saw your ad.', 'When anyone in your line buys ads, the contract pays you in that same transaction. Public on Polygonscan, nothing held, nothing to withdraw.'], + cta: 'Claim your welcome-back credits', sub: 'Free account by email. No password, no wallet today. Use the email you had on ' + brand + ': the credits are tied to it.', video: false } + : { eyebrow: 'For former ' + brand + ' members', h: 'Same daily habit. Real payouts on-chain.', + lead: 'You viewed ads on ' + brand + '. Here you view ads to earn credits, run your own campaign with them for free, and when anyone in your line buys ads you are paid in POL to your own wallet in the same transaction.', + points: ['Welcome-back credits on day one, so your first campaign runs before you have viewed a single ad.', 'Join with just an email. No password, no wallet today. Link a wallet later, only when you want payouts switched on.', 'Every payout is a public transaction on Polygon. Nothing is held, so there is nothing to withdraw and nothing to wait for.'], + cta: 'Claim your welcome-back credits', sub: 'Free account by email. Use the email you had on ' + brand + ': the credits are tied to it.', video: false }; + const ANGLES = { + 'fw-adv': LEG('Faucet Wave', 'adv'), 'fw-earn': LEG('Faucet Wave', 'earn'), 't1-adv': LEG('Tier One Ads', 'adv'), 't1-earn': LEG('Tier One Ads', 'earn'), + instant: { eyebrow: 'Same-transaction payouts', h: 'Paid before the page reloads.', lead: 'What if your commission landed before the thank-you page finished loading? On LinkSpin that is not a metaphor. A smart contract on Polygon splits every ad package the moment it sells.', + points: ['A verified contract splits every package in the same transaction it sells: 50 percent to the sponsor, 20 and 10 up the line, 20 to the platform.', 'It lands in your own wallet in seconds. There is no balance to withdraw because nothing is ever held.', 'Every payment is public on Polygonscan, so you can check the claim before you spend a dollar.'], + cta: 'See a payout land in seconds', sub: 'Free account by email. No password, no wallet today. Your invite link is live the moment you are in.' }, + adspend: { eyebrow: 'For marketers who buy traffic', h: 'You were buying ads anyway.', lead: 'Every ad dollar you ever spent went one direction: out. Here the ad spend in your line pays you, in the same transaction, on a public ledger. Seven formats, dwell-timed views, packages from $5.', + points: ['Seven formats: banners, text ads, login ads, solo ads to member inboxes, video, featured links and verified visits.', 'Views are timed on the server, so a real person saw your ad. Banners and text also run across a partner ad network.', 'When anyone in your line buys ads, the contract pays you in that same transaction.'], + cta: 'Put your next ad dollar where it pays you back', sub: 'Free account by email. See every format and the live rates before you buy anything.' }, + free: { eyebrow: 'Costs nothing to try', h: 'Watch first. Spend never.', lead: 'Join free, view a few ads, earn credits, and run your first campaign for zero dollars. Upgrade only if you want more reach.', + points: ['Join with just an email. No password, no wallet today.', 'View a few ads each day and earn credits you can spend on your own banner or text campaign.', 'Buy a package only if you want more reach. They start at $5, and every payout on them is public.'], + cta: 'Start for free. No card, no wallet.', sub: 'Type your email and we send a 6-digit code. That is the whole signup.' }, + ledger: { eyebrow: 'No back office', h: 'No back office. No payday.', lead: 'Your last program paid you on the 15th, if it paid you. Here every payout is a public transaction on Polygon you can read yourself, and nothing is ever held.', + points: ['Every payout is a public transaction on Polygon. Click it, read it, verify it yourself.', 'The contract holds zero balance. It splits and sends in the same transaction, with no pause switch and no upgrade path.', 'No back office means nobody can delay, reverse or review your commission.'], + cta: 'Check the ledger yourself, then decide', sub: 'Free account by email. The public ledger and the verified contract are one click from your dashboard.' }, + two: { eyebrow: 'The referral side, exactly as coded', h: 'Two buyers open level two.', lead: 'Every direct buyer pays you 50 percent from their first package. Two qualifying buyers open level two, five open level three. Written as constants in a verified contract.', + points: ['Every direct buyer pays you 50 percent from their very first package.', 'Two qualifying buyers open level two at 20 percent. Five open level three at 10 percent. Constants in a verified contract.', 'Until a level opens, its share climbs to the next qualified member above, so the plan rewards the people who build.'], + cta: 'Start your line. Two buyers is the target.', sub: 'Free account by email. Your invite link and the team-building plays are waiting inside.' } + }; + // ?name=First personalises the page (site-owner links, Marty 2026-09-12): letters, spaces, hyphens, + // apostrophes only, 30 chars. On the company placement links it says the top spot is reserved. + const who = String(new URLSearchParams(location.search).get('name') || '').replace(/[^A-Za-z\u00C0-\u024F' -]/g, '').trim().slice(0, 30); + if (who) { + const top = /^\/join\/(company|top)$/i.test(location.pathname); + const h = $('jnHello'); + if (h) { h.textContent = who + (top ? ', your spot directly under the company at the top is reserved for you. Activate with the $20 package to claim it.' : ', this invitation is for you.'); h.hidden = false; } + } + const v = new URLSearchParams(location.search).get('v') || (document.cookie.match(/(?:^|; )iap\.angle=([^;]+)/) || [])[1] || ''; + const a = v && ANGLES[v]; + if (a) { + document.body.classList.add('squeeze'); // server sets it too; this covers cached HTML + $('jnEyebrow').textContent = '· ' + a.eyebrow; $('jnHead').innerHTML = a.h; $('jnLead').textContent = a.lead; + if (a.cta) { $('jnCapH').textContent = a.cta; $('jnCapSub').textContent = a.sub || ''; } + // the matching hook video + this angle's three points replace the worked example + const VID = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/'; + const vid = $('jnVideo'); + if (a.video === false) vid.hidden = true; else { vid.src = VID + v + '.mp4'; vid.poster = VID + v + '.jpg'; } + $('jnPoints').innerHTML = (a.points || []).map(t => '
  • ' + t.replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])) + '
  • ').join(''); + $('jnMock').hidden = true; $('jnAngle').hidden = false; $('jnPoints').hidden = false; + } + + async function api(path, body) { + const r = await (await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })).json(); + if (r.error) throw new Error(r.error); + return r; + } + const err = m => { const e = $('jnErr'); e.textContent = m || ''; e.hidden = !m; }; + function busy(btn, fn) { + return async () => { if (btn.disabled) return; btn.disabled = true; err(''); try { await fn(); } catch (e) { err(e.message || 'Something went wrong.'); } finally { btn.disabled = false; } }; + } + const codeOpts = () => ({ honeypot: $('jnWebsite'), host: $('jnCheck') }); + const send = busy($('jnSend'), async () => { + const r = await IAP.requestCode($('jnEmail').value, codeOpts()); + $('jnCodeRow').hidden = false; $('jnVerify').hidden = false; $('jnSend').hidden = true; $('jnResend').hidden = false; + if (r.devCode) $('jnCode').value = r.devCode; + // the last thing they see before the account is created: who they are joining under + if (sponsorName) { $('jnUnder').textContent = 'Joining under ' + sponsorName + '. Not who invited you? Open their invite link first, then come back for the code.'; $('jnUnder').hidden = false; } + IAP.status(r.sent ? 'Code sent. Check your inbox (and spam, the first time).' : 'Dev mode: code filled in.', 'ok'); + $('jnCode').focus(); + }); + $('jnSend').addEventListener('click', send); + $('jnResend').addEventListener('click', busy($('jnResend'), async () => { + const r = await IAP.requestCode($('jnEmail').value, codeOpts()); + if (r.devCode) $('jnCode').value = r.devCode; + IAP.status('Fresh code sent.', 'ok'); + })); + $('jnVerify').addEventListener('click', busy($('jnVerify'), async () => { + await api('/api/auth/email/verify', { email: $('jnEmail').value, code: $('jnCode').value, newsletter: !!$('jnNews').checked, followups: !!$('jnNews').checked }); + IAP.status('You are in. Taking you to your dashboard…', 'ok'); + location.href = '/my?welcome=1'; // dashboard runs the username step + welcome tour on arrival + })); + $('jnEmail').addEventListener('keydown', e => { if (e.key === 'Enter') ($('jnVerify').hidden ? $('jnSend') : $('jnVerify')).click(); }); + $('jnCode').addEventListener('keydown', e => { if (e.key === 'Enter') $('jnVerify').click(); }); + + // sponsor line (the link opened most recently sets the sponsor; it locks at account creation) + let sponsorName = ''; + const linkTok = (location.pathname.match(/^\/join\/([^/?#]+)/) || [])[1] || ''; + fetch('/api/sponsor' + (linkTok ? '?ref=' + encodeURIComponent(linkTok) : '')).then(r => r.json()).then(sp => { + if (sp && sp.invited && sp.name) { + sponsorName = sp.name; + $('jnSponName').textContent = sp.name + (sp.own ? ' (this is your own invite page; visitors see your name here)' : ''); + if (sp.avatarUrl) { $('jnSponImg').src = sp.avatarUrl; $('jnSponImg').hidden = false; } + if (sp.cobrand && sp.bio && $('jnSponBio')) { $('jnSponBio').textContent = sp.bio; $('jnSponBio').hidden = false; $('jnSpon').classList.add('cobrand'); } + $('jnSpon').hidden = false; + } + }).catch(() => {}); + // package ladder (dollar constants + live POL quote) + const NAMES = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' }; + fetch('/api/catalog').then(r => r.json()).then(c => { + const wrap = $('jnLadder'); if (!wrap || !c.products) return; + wrap.innerHTML = c.products.filter(p => p.active !== false).map(p => + '
    ' + (NAMES[p.id] || 'Package ' + p.id) + '
    ' + IAP.fmtUsd(p.priceCents) + '
    ' + Number(p.creditAmount).toLocaleString() + ' credits
    ' + + (p.costWei ? '
    ' + IAP.fmtPol(p.costWei) + ' POL now
    ' : '') + '
    ').join(''); + }).catch(() => {}); + fetch('/api/stats').then(r => r.json()).then(s => { + if (s && s.onchainMembers) $('jnStats').textContent = s.onchainMembers.toLocaleString() + (s.onchainMembers === 1 ? ' member' : ' members') + ' on-chain so far.'; + }).catch(() => {}); +})(); diff --git a/public/assets/launch.js b/public/assets/launch.js new file mode 100644 index 0000000..e0468af --- /dev/null +++ b/public/assets/launch.js @@ -0,0 +1,114 @@ +// Founding week checklist: members only; every item reads from the live account +// (IAP.launchChecks in common.js is shared with the dashboard's "launch ready" mark). +(async function () { + const $ = id => document.getElementById(id); + try { await IAP.renderNav('training'); } catch (e) {} + let me = null, cfg = {}; + try { me = await (await fetch('/api/me')).json(); } catch (e) {} + // buyerCount and the referral list live on the dashboard endpoint; merge it in + if (me && me.signedIn) { try { const d = await (await fetch('/api/my/dashboard')).json(); if (d && !d.error) me = Object.assign({}, me, d, { signedIn: true, email: me.email }); } catch (e) {} } + try { cfg = await IAP.getConfig(); } catch (e) {} + const signedIn = !!(me && me.signedIn && me.email); + $('gate').style.display = signedIn ? 'none' : 'block'; + $('body').style.display = signedIn ? 'block' : 'none'; + if (!signedIn) return; + const pb = $('printBtn'); if (pb) pb.addEventListener('click', () => window.print()); + const tok = me.username || me.refCode || me.memberId; + if (tok) document.querySelectorAll('[data-link]').forEach(el => { el.textContent = location.origin + '/join/' + tok; }); + // launch week swipes: four promoter emails, the member's link + FOUNDER code filled in (Marty, 2026-09-15) + const LINK = location.origin + '/join/' + (tok || '') + '?promo=FOUNDER'; + const SW = [ + { when: 'Day 1 · six days out', subject: 'Something opens Monday. I am bringing a few people in early.', body: `Quick one, because I want you positioned before this goes public. + +I have been inside a new advertising platform for a week. It pays sponsors in the same transaction a package is bought, wallet to wallet, on the Polygon ledger where anyone can check it. No back office. No payday. I have watched the payouts land. + +It opens to everyone Monday, September 21 at 9 AM Central. Founding members are in now, and the people they bring in before then are the ones the launch traffic lands under. + +Joining is free with an email. This week the code FOUNDER gives you 500 ad credits to run a campaign on day one: + +{link} + +No income is promised. It is an ad platform with a referral program. But the payment mechanics are real and public, and that is why I am in. + +Look before Monday. I will walk you through the first three steps.`}, + { when: 'Day 2 · five days out', subject: 'Five hundred free credits, and what you can do with them', body: `Yesterday I sent you a link. Here is what is on the other side of it. + +You join free with your email. Every day you view a short set of ads and earn credits. Credits run your own ads: banners, text ads, video ads, solo mailings, verified visits, across the whole network. + +This week the FOUNDER code adds 500 credits on top the moment you join, enough to launch a real campaign pointed at whatever you are promoting. Watch it deliver before Monday, when everyone else arrives. + +The other half is simple. When someone you invite buys an ad package, the contract pays you 50 percent instantly. Level two gets 20 percent, level three 10 percent. Same transaction, on the public ledger. + +{link} + +Use the code before it expires Monday morning. Then tell me you are in and I will show you the checklist.`}, + { when: 'Day 4 · the weekend', subject: 'The two people you bring in this weekend', body: `The doors open Monday morning and I am spending the weekend getting my first people set up. I want you to be one of them. + +Here is why the weekend matters. Everyone who joins now is a founding member. The traffic that arrives Monday lands under whoever is already in and set up. Two people under you before Monday, and your line is built when the wave hits. + +Setup takes ten minutes: username, link a wallet, switch on payouts. Then share your link. The site gives you posts, swipes, banners and a leaderboard with weekly credit prizes for the top sponsors. + +Join with the FOUNDER code and the 500 credits are yours: + +{link} + +Message me when you are in and I will go through the checklist with you before Monday.`}, + { when: 'Day 6 · Sunday evening', subject: 'Doors open tomorrow at 9 AM Central', body: `Last note before the launch. + +LinkSpin opens to the public tomorrow, Monday, at 9 AM Central. After that the FOUNDER code is gone. Tonight it still gives you 500 free ad credits. + +If you join tonight you are in as a founding member with a campaign already funded and your link ready when the first wave arrives. If you wait, you join the wave. + +Free to join, real advertising, and every payment lands in your own wallet the second it happens, on a ledger anyone can read. No income promises. Just mechanics you can verify. + +{link} + +I will be online from 8 AM tomorrow walking people through the first three steps. Get in tonight and you are ahead of them.`} + ]; + // short posts and a DM for the same days, one per day, link + code included + const POSTS = [ + { when: 'Day 1 · six days out', text: `Something I have been inside for a week opens to the public Monday, September 21 at 9 AM Central. An ad platform that pays sponsors in the same transaction a package is bought, on the Polygon ledger where anyone can check it. Free to join. Code FOUNDER gives you 500 ad credits before then. No income promises, just mechanics you can verify. {link}` }, + { when: 'Day 2 · five days out', text: `500 free ad credits for joining before Monday. View a short daily set of ads, earn more, run banners, text ads, video ads and solo mailings across the network. When someone you invite buys a package, the contract pays you 50 percent instantly, wallet to wallet. Code FOUNDER. {link}` }, + { when: 'Day 4 · the weekend', text: `The doors open Monday morning. Everyone joining this weekend is a founding member, and the launch traffic lands under whoever is already set up. Ten minutes to set up, 500 credits with code FOUNDER, and a leaderboard that pays weekly credit prizes to the top sponsors. {link}` }, + { when: 'Day 6 · Sunday evening', text: `Doors open tomorrow, 9 AM Central. Tonight the FOUNDER code still gives you 500 free ad credits; tomorrow it is gone. Free to join, real advertising, every payment on a public ledger. Get in tonight and you are ahead of the wave. {link}` }, + { when: 'Text or DM · any day', text: `Hey, I am in something that opens publicly Monday and I am bringing a few people in early. Free to join, real advertising, every payment lands in your own wallet the second it happens. Use code FOUNDER for 500 free credits. Ten minutes to set up and I will walk you through it: {link}` } + ]; + const pl = $('postList'); + if (pl) { + pl.innerHTML = POSTS.map((s, i) => '
    ' + s.when + '
    ' + s.text.replace('{link}', LINK).replace(/&/g, '&').replace(/
    ').join(''); + pl.querySelectorAll('[data-post]').forEach(b => b.addEventListener('click', async () => { + const t = POSTS[Number(b.dataset.post)].text.replace('{link}', LINK); + try { await navigator.clipboard.writeText(t); b.textContent = 'Copied'; setTimeout(() => { b.textContent = 'Copy post'; }, 1500); } catch (e) { IAP.status('Copy failed; select the text by hand.', 'bad'); } + })); + } + const wrap = $('swipeList'); + if (wrap) { + wrap.innerHTML = SW.map((s, i) => '
    Email ' + (i + 1) + ' · ' + s.when + '
    Subject: ' + s.subject + '
    ' + s.body.replace('{link}', LINK).replace(/&/g, '&').replace(/
    ').join(''); + wrap.querySelectorAll('[data-swipe]').forEach(b => b.addEventListener('click', async () => { + const s = SW[Number(b.dataset.swipe)]; const text = 'Subject: ' + s.subject + '\n\n' + s.body.replace('{link}', LINK); + try { await navigator.clipboard.writeText(text); b.textContent = 'Copied'; setTimeout(() => { b.textContent = 'Copy email'; }, 1500); } catch (e) { IAP.status('Copy failed; select the text by hand.', 'bad'); } + })); + } + + // launch moment (admin: Settings > launchAt, ISO 8601 with offset) + const at = cfg.launchAt ? new Date(cfg.launchAt) : null; + if (at && !isNaN(at)) { + $('lwWhen').hidden = false; + $('lwWhenAt').textContent = at.toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + const tick = () => { const ms = at - Date.now(); if (ms <= 0) { $('lwWhenIn').textContent = 'doors are open'; return; } + const d = Math.floor(ms / 86400000), h = Math.floor(ms % 86400000 / 3600000), m = Math.floor(ms % 3600000 / 60000); + $('lwWhenIn').textContent = (d ? d + 'd ' : '') + h + 'h ' + m + 'm to go'; }; + tick(); setInterval(tick, 30000); + } + + function render() { + const items = IAP.launchChecks(me); + const done = items.filter(i => i.done).length; + $('lwDone').textContent = done; $('lwBar').style.width = Math.round(done / items.length * 100) + '%'; + $('chk').innerHTML = items.map((i, n) => '
  • ' + (i.done ? '✓' : (n + 1)) + '
    ' + + '

    ' + i.title + '

    ' + i.how + '

    ' + i.why + '
    ' + (i.note ? '

    ' + i.note + '

    ' : '') + '
    ' + + '
    ' + (i.manual ? '' : '' + i.cta + '') + '
  • ').join(''); + $('chk').querySelectorAll('[data-manual]').forEach(b => b.addEventListener('click', () => { IAP.launchToggle(b.dataset.manual); render(); })); + } + render(); +})(); diff --git a/public/assets/ledger.js b/public/assets/ledger.js new file mode 100644 index 0000000..431f76f --- /dev/null +++ b/public/assets/ledger.js @@ -0,0 +1,31 @@ +// Live ledger: recent history + SSE stream of new chain events. +(async function () { + await IAP.renderNav('ledger'); + const c = await IAP.getConfig(); + IAP.$('contractLink').href = c.explorer ? c.explorer + '/address/' + c.contract : '/contract'; + const feed = IAP.$('feed'); + + const { events } = await (await fetch('/api/feed?n=150')).json(); + feed.innerHTML = ''; + if (!events.length) feed.innerHTML = '
    No activity yet. The first purchase will appear here the moment it lands.
    '; + for (const ev of events) feed.appendChild(IAP.feedRow(ev, c)); + + try { + const stats = await (await fetch('/api/stats')).json(); + IAP.$('statLine').textContent = stats.onchainMembers + ' on-chain member(s)'; + } catch (e) {} + + IAP.adSlot('banner', 'adSlotBanner'); + IAP.adSlot('text', 'adSlotText'); + + const es = new EventSource('/api/feed/live'); + es.onopen = () => { const b = IAP.$('liveBadge'); b.textContent = '● live'; }; + es.onerror = () => { const b = IAP.$('liveBadge'); b.textContent = 'reconnecting…'; }; + es.onmessage = m => { + try { + const ev = JSON.parse(m.data); + feed.prepend(IAP.feedRow(ev, c)); + while (feed.children.length > 200) feed.removeChild(feed.lastChild); + } catch (e) {} + }; +})(); diff --git a/public/assets/legal.js b/public/assets/legal.js new file mode 100644 index 0000000..9494607 --- /dev/null +++ b/public/assets/legal.js @@ -0,0 +1,2 @@ +// legal/static content pages: render the shared nav + footer +(async function () { try { await IAP.renderNav(''); } catch (e) {} })(); diff --git a/public/assets/my.js b/public/assets/my.js new file mode 100644 index 0000000..0963e41 --- /dev/null +++ b/public/assets/my.js @@ -0,0 +1,2707 @@ +// My account: email-first join/login, wallet link at purchase time, +// free payout activation, invite link, on-chain activity. +(async function () { + // back-office shell: no marketing nav here; rehearsal notice lives in the top bar + const $ = IAP.$; + try { + const c = await IAP.getConfig(); + if (c.rehearsal && $('boRehearsal')) { + $('boRehearsal').hidden = false; + $('boRehearsal').innerHTML = 'Testnet rehearsal · ' + c.chainName; + } + if (c.rehearsal && $('faucetCard')) $('faucetCard').hidden = false; + } catch (e) {} + // if they arrived through a sponsor's link, show who they're joining under + (async () => { + try { + const sp = await (await fetch('/api/sponsor')).json(); + if (sp && sp.invited && sp.name && $('sponsorNote')) { $('sponsorNoteName').textContent = sp.name; $('sponsorNote').hidden = false; } + } catch (e) {} + })(); + + async function api(path, body) { + const r = await (await fetch(path, { method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })).json(); + if (r.error) throw new Error(r.error); + return r; + } + + function nextMove(d) { + if (!d.address) return 'Grab your link below and start sharing today. Then link your wallet (one free signature) so every payment can lock to you before your people start buying.'; + if (!d.memberId) return 'Your wallet is linked. Switch on payouts above (one free transaction) and every purchase in your line pays you the moment it happens.'; + if (d.buyerCount === 0) return 'Payouts are on and your link is live. Your next milestone: your first buyer of $20 or more. Every direct pays you 50 percent from their very first package.'; + if (d.buyerCount === 1) return 'One qualifying buyer down, one to go. Your next $20+ buyer unlocks level 2: 20 percent of everything your people’s people buy.'; + if (d.buyerCount < 5) return 'Level 2 is open. ' + (5 - d.buyerCount) + ' more qualifying buyer(s) unlock level 3 and the full three-level flow.'; + return 'Fully qualified. Every level pays you, and you catch the pass-ups that under-qualified positions below you let slip. Keep sharing and keep your campaigns running.'; + } + // featured rotation strip on the overview + let featItems = [], featIdx = 0, featTimer = null; + function renderOneFeatured() { + if (!featItems.length) return; + const i = featItems[featIdx % featItems.length]; + $('featStrip').innerHTML = '' + esc(i.title) + '' + + (i.by ? '' + esc(i.by) + '' : '') + ''; + } + async function loadFeatured() { + try { + const r = await (await fetch('/api/featured')).json(); + const card = $('featuredCard'); if (!card) return; + if (!r.items || !r.items.length) { card.hidden = true; return; } + card.hidden = false; + featItems = r.items; featIdx = Math.floor(Math.random() * featItems.length); + $('featSub').textContent = r.items.length + ' link' + (r.items.length === 1 ? '' : 's') + ' in rotation'; + renderOneFeatured(); // show ONE at a time (true rotation), cycle if more than one + clearInterval(featTimer); + if (featItems.length > 1) featTimer = setInterval(() => { featIdx++; renderOneFeatured(); }, 6000); + // self-sell: today's open slots + a CTA to feature your own link + try { + const st = await (await fetch('/api/featured/stats')).json(); + const today = (st.occupancy || []).find(d => d.offset === 0); + const cta = $('featCta'); + if (cta) { + cta.innerHTML = (today ? '' + today.open + ' of ' + today.cap + ' featured slots open today. ' : '') + + 'Feature your link →'; + const fb = $('featBuy'); + if (fb) fb.addEventListener('click', e => { e.preventDefault(); setPane('campaigns'); const ct = $('cType'); if (ct) { ct.value = 'featured'; ct.dispatchEvent(new Event('change')); } }); + } + } catch (e) {} + } catch (e) {} + } + + // achievement badges: the milestone ladder as unlockable medals + share-to-image + const BADGES = [ // ribbonY = vertical center of each badge's name ribbon (art differs per tier) + { key: 'payouts', label: 'Spark', sub: 'payouts on', img: '/badges/badge-spark.jpg', ribbonY: 0.728 }, + { key: 'firstBuyer', label: 'Surge', sub: 'first qualifying buyer', img: '/badges/badge-surge.jpg?v=2', ribbonY: 0.76 }, + { key: 'level2', label: 'Circuit', sub: '2 qualifying buyers', img: '/badges/badge-circuit.jpg?v=2', ribbonY: 0.72 }, + { key: 'level3', label: 'Nexus', sub: 'fully qualified', img: '/badges/badge-nexus.jpg?v=2', ribbonY: 0.73 } + ]; + let lastMilestones = []; + function renderBadges(d) { + const reached = d.milestonesReached || []; + lastMilestones = reached; + const strip = $('badgeStrip'); + if (!strip) return; + $('badgeCard').hidden = false; + strip.innerHTML = BADGES.map(b => { + const got = reached.includes(b.key); + return '
    ' + + '' + b.label + ' badge' + + '
    ' + b.label + '
    ' + b.sub + '
    ' + + (got ? ' ' : '
    🔒 locked
    ') + '
    '; + }).join(''); + strip.querySelectorAll('[data-badge]').forEach(btn => + btn.addEventListener('click', () => shareBadge(btn.dataset.badge, d))); + strip.querySelectorAll('[data-badgepost]').forEach(btn => + btn.addEventListener('click', () => postBadge(btn.dataset.badgepost, d, btn, true))); + fetch('/api/my/badge-posted').then(r => r.json()).then(r => { // the manual button is the admin's only (Marty, 2026-09-13) + if (r.canPost) strip.querySelectorAll('[data-badgepost]').forEach(b => { b.hidden = false; }); + (r.posted || []).forEach(k => { const b = strip.querySelector('[data-badgepost="' + k + '"]'); if (b) { b.textContent = 'Posted ✓'; b.disabled = true; } }); + }).catch(() => {}); + // celebrate anything newly granted this load, and post the branded badge to the team channels (Marty, 2026-09-13) + if (d.milestonesGranted && d.milestonesGranted.length) { + const total = d.milestonesGranted.reduce((s, g) => s + g.credited, 0); + const names = d.milestonesGranted.map(g => (BADGES.find(b => b.key === g.key) || {}).label).filter(Boolean).join(', '); + IAP.status('🏆 Achievement unlocked: ' + names + ' — +' + total + ' bonus credits!', 'ok'); + d.milestonesGranted.forEach((g, i) => setTimeout(() => postBadge(g.key, d, strip.querySelector('[data-badgepost="' + g.key + '"]')), 800 + i * 1500)); + } + } + // draw the badge with the member's name on the ribbon; cb(canvas) or cb(null) + function composeBadge(key, d, cb) { + const b = BADGES.find(x => x.key === key); if (!b) return cb(null); + const who = d.username ? d.username : d.memberId ? 'member #' + d.memberId : ''; + const img = new Image(); + img.onload = () => { + const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; + const x = c.getContext('2d'); + x.drawImage(img, 0, 0); + if (who) { + x.textAlign = 'center'; x.textBaseline = 'middle'; + x.font = 'bold ' + Math.round(c.width * 0.055) + 'px Sora, "Segoe UI", sans-serif'; + const y = c.height * (b.ribbonY || 0.75); + x.lineJoin = 'round'; x.lineWidth = Math.max(3, Math.round(c.width * 0.008)); + x.strokeStyle = 'rgba(4,20,15,.9)'; x.strokeText(who, c.width / 2, y); + x.fillStyle = '#ffd15c'; x.fillText(who, c.width / 2, y); + } + cb(c); + }; + img.onerror = () => cb(null); + img.src = b.img; + } + async function postBadge(key, d, btn, manual) { + if (btn && btn.disabled) return; + if (btn) { btn.disabled = true; btn.textContent = 'Posting…'; } + composeBadge(key, d, c => { + if (!c) { if (btn) { btn.disabled = false; btn.textContent = 'Post to Telegram'; } return; } + c.toBlob(async bl => { + try { + const r = await (await fetch('/api/my/badge-post?key=' + encodeURIComponent(key) + (manual ? '&manual=1' : ''), { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: bl })).json(); + if (r.error) throw new Error(r.error); + if (btn) { btn.textContent = 'Posted ✓'; btn.disabled = true; } + if (!r.already) IAP.status('Your badge is posted in the team channels.', 'ok'); + } catch (e) { if (btn) { btn.disabled = false; btn.textContent = 'Post to Telegram'; } IAP.status(e.message || 'Could not post the badge.', 'bad'); } + }, 'image/jpeg', 0.86); + }); + } + // Share: a picker (Marty, 2026-09-13). The composed badge is stored once so a public page at + // /b// carries it as the preview image; every network gets that link. + function shareBadge(key, d) { + const b = BADGES.find(x => x.key === key); if (!b) return; + if (!d.username) { IAP.status('Pick a username first; your share page carries it.', 'bad'); return; } + IAP.status('Preparing your ' + b.label + ' badge…', 'ok'); + composeBadge(key, d, c => { + if (!c) { IAP.status('Could not load the badge art.', 'bad'); return; } + c.toBlob(async bl => { + let page = null; + try { const r = await (await fetch('/api/my/badge-image?key=' + encodeURIComponent(key), { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: bl })).json(); if (r.error) throw new Error(r.error); page = r.page; } + catch (e) { IAP.status(e.message || 'Could not prepare the share page.', 'bad'); return; } + openShareSheet(b, d, bl, page); + }, 'image/jpeg', 0.9); + }); + } + function openShareSheet(b, d, blob, page) { + const text = 'I just unlocked the ' + b.label + ' badge on LinkSpin (' + b.sub + '). Advertising that pays you back, on-chain, the same second a package sells.'; + const enc = encodeURIComponent; + const links = [ + ['X', 'https://x.com/intent/tweet?text=' + enc(text) + '&url=' + enc(page)], + ['Facebook', 'https://www.facebook.com/sharer/sharer.php?u=' + enc(page)], + ['Telegram', 'https://t.me/share/url?url=' + enc(page) + '&text=' + enc(text)], + ['WhatsApp', 'https://wa.me/?text=' + enc(text + ' ' + page)], + ['LinkedIn', 'https://www.linkedin.com/sharing/share-offsite/?url=' + enc(page)] + ]; + const back = document.createElement('div'); back.className = 'modal-back'; back.style.zIndex = '200'; + back.innerHTML = ''; + document.body.appendChild(back); + const close = () => back.remove(); + back.addEventListener('click', e => { if (e.target === back) close(); }); + back.querySelector('[data-sh="close"]').addEventListener('click', close); + back.querySelector('[data-sh="copy"]').addEventListener('click', async () => { try { await navigator.clipboard.writeText(page); IAP.status('Link copied.', 'ok'); } catch (e) { IAP.status('Copy this: ' + page, 'ok'); } }); + back.querySelector('[data-sh="save"]').addEventListener('click', () => { + const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'linkspin-' + b.label.toLowerCase() + '-badge.jpg'; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); + IAP.status('Your ' + b.label + ' badge is saved.', 'ok'); + }); + // Massifly has no share intent: copy the post text + link, then open the feed composer (Marty, 2026-09-13) + back.querySelector('[data-sh="massifly"]').addEventListener('click', async () => { + try { await navigator.clipboard.writeText(text + ' ' + page); IAP.status('Post copied. Paste it into the Massifly composer; the badge preview appears from the link.', 'ok'); } + catch (e) { IAP.status('Copy this into Massifly: ' + text + ' ' + page, 'ok'); } + window.open('https://massifly.com/account', '_blank', 'noopener'); + }); + const nat = back.querySelector('[data-sh="native"]'); + if (nat) nat.addEventListener('click', async () => { + try { + const file = new File([blob], 'linkspin-' + b.label.toLowerCase() + '-badge.jpg', { type: 'image/jpeg' }); + if (navigator.canShare && navigator.canShare({ files: [file] })) await navigator.share({ files: [file], title: b.label + ' badge', text: text + ' ' + page }); + else await navigator.share({ title: b.label + ' badge', text, url: page }); + } catch (e) {} + }); + } + + // milestone stepper under "Your next move": lit nodes for what's done, + // amber glow on the current target — the same ladder the contract pays + function renderSteps(d) { + const el = $('ncSteps'); + if (!el) return; + const b = d.buyerCount || 0; + const steps = [ + { label: 'Joined', sub: 'free account', hit: true }, + { label: 'Spark', sub: 'payouts on', hit: !!d.memberId }, + { label: 'Surge', sub: 'first buyer · 50%', hit: b >= 1 }, + { label: 'Circuit', sub: '2 buyers · +20%', hit: b >= 2 }, + { label: 'Nexus', sub: '5 buyers · +10%', hit: b >= 5 } + ]; + const cur = steps.findIndex(s => !s.hit); + el.innerHTML = steps.map((s, i) => + '
    ' + + '' + (s.hit ? '✓' : i + 1) + '' + + '' + s.label + '' + s.sub + '
    ').join(''); + } + // ── overview v3 charts: hand-rolled SVG/CSS, real data only ── + const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const CH = { mint: '#43e8c3', cyan: '#54ccff', violet: '#9d7dff', amber: '#ffb238', track: 'rgba(139,166,156,.18)' }; + function bars(el, xel, data) { // data: [{v,label,tip,alt}] + if (!el) return; + const max = Math.max(1, ...data.map(d => d.v)); + el.innerHTML = data.length + ? data.map(d => '
    ').join('') + : '
    '.repeat(8); + if (xel) xel.innerHTML = data.map(d => '' + esc(d.label) + '').join(''); + } + function donut(el, segs, center) { // segs sum to the ring; r=15.9155 → circumference 100 + if (!el) return; + const total = segs.reduce((s, x) => s + x.v, 0); + let off = 25, out = ''; + if (total > 0) for (const s of segs) { + const len = s.v / total * 100; + if (len <= 0 || s.color === 'none') { off -= Math.max(0, len); continue; } + out += ''; + off -= len; + } + out += '' + esc(center.big) + '' + + '' + esc(center.small) + ''; + el.innerHTML = out; + } + function legend(el, rows) { + if (el) el.innerHTML = rows.map(r => '
    ' + + esc(r.label) + ' ' + esc(r.v) + '
    ').join(''); + } + async function loadCharts(d) { + // credit composition donut + today's viewing ring (live balances) + try { + const st = await (await fetch('/api/my/earn')).json(); + if (!st.error) { + // one rule: a balance is what is NOT committed to a live campaign. Budgets are + // set aside when a campaign starts and spend down inside Campaigns, so these + // numbers only move when a campaign is created, topped up or paused + const purchased = d.credits || 0, earned = d.earnedCredits != null ? d.earnedCredits : (st.earnedAvailable != null ? st.earnedAvailable : (st.earned || 0)), inC = d.inCampaigns || 0; + $('dbCredits').textContent = (purchased + earned).toLocaleString(); + // the Wallet tab card showed on-chain credits only, so promo/earned credits looked like 0 there (Jim, 2026-09-13) + if ($('creditLine')) $('creditLine').innerHTML = (purchased + earned).toLocaleString() + ' ' + purchased.toLocaleString() + ' purchased · ' + earned.toLocaleString() + ' earned'; + $('dbCreditsSub').textContent = purchased.toLocaleString() + ' purchased' + (d.creditedCredits ? ' (' + d.creditedCredits.toLocaleString() + ' credited to you)' : '') + ' · ' + earned.toLocaleString() + ' earned' + (inC ? ' · ' + inC.toLocaleString() + ' in campaigns' : ''); + donut($('chDonut'), + [{ v: purchased, color: CH.mint }, { v: earned, color: CH.cyan }, { v: inC, color: CH.amber }], + { big: (purchased + earned).toLocaleString(), small: 'available' }); + legend($('chDonutLegend'), [ + { color: CH.mint, label: 'Purchased, available', v: purchased.toLocaleString() }, + { color: CH.cyan, label: 'Earned, available', v: earned.toLocaleString() }, + { color: CH.amber, label: 'In live campaigns', v: inC.toLocaleString() }]); + const done = Math.min(st.views || 0, st.target || 5), left = Math.max(0, (st.target || 5) - done); + donut($('chRing'), [{ v: done, color: st.claimed ? CH.mint : CH.cyan }, { v: left, color: 'none' }], + { big: done + '/' + (st.target || 5), small: st.claimed ? 'claimed' : 'ads viewed' }); + legend($('chRingLegend'), [ + { color: st.claimed ? CH.mint : CH.cyan, label: 'Viewed today', v: done }, + { color: CH.track, label: 'To go', v: left }, + { color: CH.amber, label: 'Claim pays', v: '+' + (st.claimCredits || 0) + ' credits' }]); + } + } catch (e) {} + // recent on-chain payouts, sized to scale, oldest→newest + try { + let evs = []; + if (d.memberId) { + const a = await (await fetch('/api/my/activity')).json(); + evs = (a.earnings || []).filter(e => e.amountWei).sort((x, y) => x.block - y.block).slice(-12); + } + bars($('chEarnBars'), $('chEarnX'), evs.map(e => ({ + v: Number(BigInt(e.amountWei) / 1000000000000n) / 1e6, + label: e.type === 'AwardPaid' ? 'award' : 'L' + (e.tier || 1), + tip: IAP.fmtPol(e.amountWei) + ' POL', alt: e.type === 'AwardPaid' }))); + if (!evs.length) $('chEarnSub').textContent = d.memberId + ? 'no payouts yet — share your link' : 'activate a package to start earning'; + } catch (e) {} + // campaign delivery: impressions per campaign + try { + const r = await (await fetch('/api/my/campaigns')).json(); + const cs = (r.campaigns || []).slice(0, 8); + bars($('chCampBars'), $('chCampX'), cs.map(c => ({ + v: c.imps || 0, label: String(c.name || c.type).slice(0, 9), + tip: (c.name || c.type) + ': ' + (c.imps || 0) + ' imps · ' + (c.clicks || 0) + ' clicks', + alt: c.type === 'text' }))); + if (!cs.length) $('chCampSub').textContent = 'no campaigns yet — place your first ad'; + } catch (e) {} + } + // ── live updates: subscribe to the chain event stream so the members area + // reacts to on-chain changes (payouts, qualifications) without a refresh ── + let MYID = 0; + // synthesized sounds (no asset files; CSP-safe). cha-ching on a payment, pop on a message. + function playSound(kind) { + try { + const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return; + const ctx = window.__iapAC || (window.__iapAC = new AC()); + if (ctx.state === 'suspended') ctx.resume(); + const now = ctx.currentTime; + const tone = (freq, at, dur, type, peak) => { + const o = ctx.createOscillator(), g = ctx.createGain(); + o.type = type || 'sine'; o.frequency.value = freq; + g.gain.setValueAtTime(0.0001, now + at); + g.gain.exponentialRampToValueAtTime(peak || 0.22, now + at + 0.02); + g.gain.exponentialRampToValueAtTime(0.0001, now + at + dur); + o.connect(g).connect(ctx.destination); o.start(now + at); o.stop(now + at + dur + 0.02); + }; + if (kind === 'chaching') { tone(1318, 0, 0.34, 'sine', 0.25); tone(1760, 0.09, 0.4, 'sine', 0.25); } + else { tone(680, 0, 0.16, 'triangle', 0.18); tone(1020, 0.05, 0.16, 'triangle', 0.16); } + } catch (e) {} + } + function startLiveFeed() { + if (window.__iapFeed || !window.EventSource) return; + try { + const es = new EventSource('/api/feed/live'); + window.__iapFeed = es; + es.onmessage = m => { let ev; try { ev = JSON.parse(m.data); } catch (e) { return; } handleLiveEvent(ev); }; + // browser auto-reconnects on error; nothing to do + } catch (e) {} + } + let liveRefreshT = null; + function liveRefresh() { // debounce a burst of events into one refresh + clearTimeout(liveRefreshT); + liveRefreshT = setTimeout(() => { loadDashboard(); try { loadLineage(); } catch (e) {} }, 600); + } + // community toasts (Marty, 2026-09-12: keep the dashboard feeling alive): everyone else's joins, + // purchases, payouts, qualifications, tank arrivals and adoptions, in a second, quieter toast so + // they never replace a personal one. At most one every 4 s; a burst shows the latest. + let liveT = 0, liveQ = null; + function communityToast(msg) { + const now = Date.now(); + if (now - liveT < 4000) { liveQ = msg; if (!communityToast._q) communityToast._q = setTimeout(() => { communityToast._q = null; const m = liveQ; liveQ = null; if (m) communityToast(m); }, 4200 - (now - liveT)); return; } + liveT = now; + let el = document.getElementById('liveToast'); + if (!el) { el = document.createElement('div'); el.id = 'liveToast'; el.setAttribute('role', 'status'); el.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:90;max-width:min(360px,calc(100vw - 32px));background:var(--panel-solid);border:1px solid var(--line-strong);border-left:3px solid var(--mint);border-radius:12px;padding:10px 14px;font-size:14px;box-shadow:0 10px 30px rgba(0,0,0,.4);transition:opacity .3s'; document.body.appendChild(el); } + el.textContent = msg; el.hidden = false; el.style.opacity = '1'; + clearTimeout(communityToast._t); communityToast._t = setTimeout(() => { el.style.opacity = '0'; setTimeout(() => { el.hidden = true; }, 350); }, 6000); + } + function handleLiveEvent(ev) { + if (!ev || !ev.type) return; + const nm = id => (ev.names && ev.names[id]) ? '@' + ev.names[id] : 'member #' + id; + // site-wide activity (not about me): joins, tank, adoptions, purchases, payouts, qualifications + if (ev.type === 'Joined') { communityToast('👋 ' + ev.name + ' just joined' + (ev.tank ? ' and is waiting for a sponsor in the holding tank' : '')); liveRefresh(); return; } + if (ev.type === 'Adopted') { communityToast('🤝 ' + ev.sponsor + ' picked up ' + ev.member + ' from the holding tank'); liveRefresh(); return; } + if (ev.type === 'Contest') { communityToast('🏆 ' + ev.winner + ' won the ' + (ev.kind === 'week' ? 'weekly' : 'monthly') + ' referral contest'); return; } + if (ev.type === 'Badge') { communityToast('🏆 ' + ev.member + ' unlocked ' + ev.label); return; } + if (ev.type === 'Released') { communityToast('🪣 ' + ev.sponsor + ' returned ' + ev.member + ' to the holding tank'); liveRefresh(); return; } + const mine = MYID && (ev.recipientId === MYID || ev.toId === MYID || ev.sponsorId === MYID || ev.skippedId === MYID || ev.buyerId === MYID); + if (!mine) { + if (ev.type === 'Purchase' && ev.buyerId) communityToast('🧾 ' + nm(ev.buyerId) + ' just bought a $' + Math.round((ev.priceCents || 0) / 100) + ' package'); + else if (ev.type === 'TierPaid' && ev.recipientId) communityToast('💸 ' + nm(ev.recipientId) + ' just got paid ' + IAP.fmtPol(ev.amountWei) + ' POL'); + else if (ev.type === 'BuyerCounted' && ev.sponsorId) communityToast('🎯 ' + nm(ev.sponsorId) + ' now has ' + ev.newCount + ' qualifying buyer' + (ev.newCount === 1 ? '' : 's')); + else if (ev.type === 'MemberActivated' && ev.id) communityToast('⚡ ' + nm(ev.id) + ' switched on payouts'); + return; + } + if (!MYID) return; + let toast = null, kind = 'ok'; + let sound = null; + if (ev.type === 'TierPaid' && ev.recipientId === MYID) { toast = '💸 You earned a level-' + ev.tier + ' payout of ' + IAP.fmtPol(ev.amountWei) + ' POL!'; sound = 'chaching'; } + else if (ev.type === 'AwardPaid' && ev.toId === MYID) { toast = '💸 You received ' + IAP.fmtPol(ev.amountWei) + ' POL!'; sound = 'chaching'; } + else if (ev.type === 'BuyerCounted' && ev.sponsorId === MYID) toast = '🎯 A referral just qualified — you now have ' + ev.newCount + ' qualifying buyer' + (ev.newCount === 1 ? '' : 's') + '!'; + else if (ev.type === 'PassedUp' && ev.skippedId === MYID) { toast = '⚠️ A level-' + ev.tier + ' payout passed you by. Get qualified to catch these.'; kind = 'bad'; } + else if (ev.type === 'Purchase' && ev.buyerId === MYID) toast = '✅ Purchase settled on-chain — your credits are updated.'; + else if (ev.type === 'MemberActivated' && ev.sponsorId === MYID) toast = '🤝 A new member just activated in your line!'; + if (toast) { IAP.status(toast, kind); if (sound) playSound(sound); liveRefresh(); } + } + + // training center: videos + materials (admin-curated via data/training.json) + async function loadTraining() { + try { + const r = await (await fetch('/api/training')).json(); + const el = $('trainingList'); if (!el) return; + const items = r.items || []; + if (!items.length) { el.innerHTML = '

    Training materials are being added. Check back soon.

    '; return; } + // section pills at the top: one per group, plus All; the active pill filters the list + const groups = [...new Set(items.map(it => it.group).filter(Boolean))]; + const pills = $('trainingPills'); + const want = loadTraining.filter || 'all'; + if (pills) { + pills.hidden = groups.length < 2; + pills.innerHTML = ['all', ...groups].map(g => '').join(''); + pills.querySelectorAll('[data-tg]').forEach(b => b.addEventListener('click', () => { loadTraining.filter = b.dataset.tg; loadTraining(); })); + } + const shown = want === 'all' ? items : items.filter(it => it.group === want); + let lastGroup = null; + el.innerHTML = shown.map(it => { + // optional section header: entries carry a `group`; a header renders when it changes + let head = ''; + if (it.group && it.group !== lastGroup) { lastGroup = it.group; head = '

    ' + esc(it.group) + '

    '; } + const isVid = it.videoUrl && /\.(mp4|webm)(\?|$)/i.test(it.videoUrl); + // poster: the admin list can carry one; otherwise assume a .jpg next to the .mp4 (the pipeline uploads both) + const poster = it.posterUrl || (isVid ? it.videoUrl.replace(/\.(mp4|webm)(\?.*)?$/i, '.jpg$2') : ''); + const media = isVid ? '' : ''; + const links = []; + if (it.videoUrl && !isVid) links.push('Watch'); + if (it.docUrl) links.push('Open material'); + return head + '

    ' + esc(it.title || 'Lesson') + '

    ' + + (it.desc ? '

    ' + esc(it.desc) + '

    ' : '') + + (media ? '

    ' + media + '

    ' : '') + + (links.length ? '

    ' + links.join(' ') + '

    ' : '') + '
    '; + }).join(''); + } catch (e) {} + } + // visual line tree on the Overview: YOU + three levels, qualified directs in gold + async function loadLineTree(buyerCount) { + try { + const r = await (await fetch('/api/my/line')).json(); + const el = $('lineTree'); if (!el) return; + const levels = r.levels || []; + const total = levels.reduce((n, L) => n + L.members.length, 0); + if ($('treeSub')) $('treeSub').textContent = total ? total + ' across 3 levels' : 'share your link to grow'; + const chip = (m, q) => '' + esc(String(m.name || 'M').replace(/^@/, '').slice(0, m.own ? 20 : 14)) + (m.buyers ? '' + m.buyers + '' : '') + ''; + const rowFor = lvl => { + const L = levels.find(x => x.level === lvl); const members = L ? L.members : []; + // gold = the contract counted this member as one of your qualifying buyers (not "the first N chips") + let html = members.map(m => chip(m, !!(m.qualified || m.bought))).join(''); // gold on every level = made their $20+ buy + if (lvl <= 2 && members.length < (lvl === 1 ? 2 : 4)) html += '+ open'; + if (!html) html = '+ open'; + return '
    L' + lvl + '
    ' + html + '
    '; + }; + el.innerHTML = '
    YOU
    ' + rowFor(1) + rowFor(2) + rowFor(3); + } catch (e) {} + } + // "What's new" card: latest notes + what is being built; a dot marks notes newer than the member's last look (Marty, 2026-09-14) + let newsLoaded = false; + async function loadNews() { + if (newsLoaded || !$('newsCard')) return; newsLoaded = true; + try { + const r = await (await fetch('/api/releases')).json(); + if (!r.notes.length && !r.roadmap.length) return; + let seen = ''; try { seen = localStorage.getItem('iap.news.seen') || ''; } catch (e) {} + const esc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const notes = r.notes.slice(0, 3).map(n => '

    ' + (n.date > seen ? ' ' : '') + '' + esc(n.title) + ' ' + esc(n.date) + '

    ').join(''); + const rm = r.roadmap.filter(x => x.status === 'building').slice(0, 2).map(x => '

    Building: ' + esc(x.title) + (x.eta ? ' (' + esc(x.eta) + ')' : '') + '

    ').join(''); + $('newsList').innerHTML = notes + rm + '

    All release notes and the roadmap →

    '; + $('newsCard').hidden = false; + $('newsCard').addEventListener('click', () => { try { localStorage.setItem('iap.news.seen', r.latest || ''); } catch (e) {} $('newsList').querySelectorAll('span[style*="mint"]').forEach(s => { if (s.textContent === '●') s.remove(); }); }, { once: true }); + } catch (e) {} + } + // Leaderboard card: top 5 this week + your own rank + the prize (Marty, 2026-09-14) + async function loadLeaderboard() { + if (!$('lbCard')) return; + try { + const r = await (await fetch('/api/leaderboard?period=week')).json(); + const esc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + let h = r.prize ? '

    Prize this week: ' + esc(r.prize) + '

    ' : ''; + h += r.top.length ? '' + r.top.slice(0, 5).map(x => '').join('') + '
    ' + x.rank + '' + esc(x.name) + '' + x.sales + ' sold · $' + x.usd + '
    ' + : '

    No packages sold yet this week. First sale takes the top spot.

    '; + h += r.me ? '

    You: #' + r.me.rank + ' · ' + r.me.sales + ' sold · $' + r.me.usd + '

    ' : '

    You: no sales yet this week. Sales are $20+ packages bought by people you sponsor.

    '; + h += '

    Full leaderboard: week, month, all time →

    '; + $('lbList').innerHTML = h; $('lbCard').hidden = false; + } catch (e) {} + } + // Getting-started stepper: username -> wallet -> payouts -> first package. The current step gets one big + // button that opens the right tab and pulses the control (Jim: "it wasn't obvious", 2026-09-14) + function renderSteps(d) { + const card = $('gsCard'); if (!card) return; + const hasUser = !!d.username, hasWallet = !!d.address, hasPayouts = !!d.memberId, hasPackage = !!(d.memberId && (d.credits || 0) > 0); + const steps = [ + { k: 'user', t: 'Pick a username', done: hasUser }, + { k: 'wallet', t: 'Link your wallet', done: hasWallet }, + { k: 'payouts', t: 'Switch on payouts', done: hasPayouts }, + { k: 'package', t: 'Your first package', done: hasPackage } + ]; + if (steps.every(x => x.done)) { card.hidden = true; return; } + const cur = steps.find(x => !x.done); + let collapsed = false; try { collapsed = localStorage.getItem('iap.gs.collapsed') === '1'; } catch (e) {} + $('gsSub').innerHTML = steps.filter(x => x.done).length + ' of 4 done · ' + (collapsed ? 'show' : 'hide') + ''; + $('gsToggle').addEventListener('click', e => { e.preventDefault(); try { localStorage.setItem('iap.gs.collapsed', collapsed ? '0' : '1'); } catch (er) {} renderSteps(d); }); + $('gsSteps').hidden = collapsed; $('gsNow').hidden = collapsed; + $('gsSteps').innerHTML = steps.map((x, i) => '
    ' + (x.done ? '✓' : i + 1) + '' + x.t + '
    ').join(''); + const copy = { + user: ['Pick your username', 'It becomes your invite link and your public page, and it is permanent.', 'Choose a username', () => showOnboard(true)], + wallet: ['Link your wallet', 'Your payouts land in a wallet you control, so the site needs to know which one is yours. One free signature, no purchase. MetaMask recommended; Phantom users switch Polygon on first. Never held crypto? The wallet guide walks you through it.', 'Link my wallet', () => jumpTo('wallet', 'linkBtn')], + payouts: ['Switch on payouts', 'One small transaction registers your wallet with the contract so commissions can reach it. Buying any package does this automatically.', 'Activate payouts', () => jumpTo('wallet', 'activateBtn')], + package: ['Your first package', 'From $5. The $20 Activation package is the one that counts you as a qualifying buyer for your sponsor and unlocks adopting from the holding tank.', 'See packages', () => jumpTo('buy', null)] + }[cur.k]; + $('gsNow').innerHTML = '

    Next: ' + copy[0] + '' + copy[1] + (cur.k === 'wallet' ? ' Wallet guide' : '') + '

    '; + $('gsGo').addEventListener('click', copy[3]); + card.hidden = false; + } + function jumpTo(pane, id) { + setPane(pane); + setTimeout(() => { const el = id && $(id); if (!el) return; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.remove('pulse'); void el.offsetWidth; el.classList.add('pulse'); try { el.focus({ preventScroll: true }); } catch (e) {} }, 250); + } + async function loadDashboard() { + try { + loadNews(); loadLeaderboard(); + const d = await (await fetch('/api/my/dashboard')).json(); + if (d.error) return; + chatSync(d); + try { renderSteps(d); } catch (e) {} + MYID = d.memberId || MYID; + startLiveFeed(); + if (!window.__lbChecked) { // daily login bonus, once per session (server guards once/day) + window.__lbChecked = true; + api('/api/my/login-bonus').then(r => { + if (r && r.granted > 0) { + IAP.status('🎁 Daily login bonus: +' + r.granted + ' credits' + (r.streak > 1 ? ' · ' + r.streak + '-day streak' : '') + '!', 'ok'); + playSound('chaching'); setTimeout(loadDashboard, 900); + } + }).catch(() => {}); + } + const earnedAv = d.earnedCredits || 0, inCamp = d.inCampaigns || 0; + $('dbCredits').textContent = ((d.credits || 0) + earnedAv).toLocaleString(); + $('dbCreditsSub').textContent = (d.credits || 0).toLocaleString() + ' purchased' + (d.creditedCredits ? ' (' + d.creditedCredits.toLocaleString() + ' credited to you)' : '') + ' · ' + earnedAv.toLocaleString() + ' earned' + (inCamp ? ' · ' + inCamp.toLocaleString() + ' in campaigns' : ''); + $('dbEarned').textContent = IAP.fmtPol(d.earnedWei || '0'); + $('dbBuyers').textContent = d.buyerCount || 0; if (d.buyerCountPositions) { const sub = $('dbBuyersSub'); if (sub) sub.textContent = '+' + d.buyerCountPositions + ' on your linked positions (count toward badges; levels pay on your main position)'; } + $('dbTeam').textContent = (d.referrals || []).length; + // trend chips: only real, computable facts + const ec = $('dbEarnedChip'); + if (ec && d.earnCount) { ec.hidden = false; ec.textContent = d.earnCount + ' instant payout' + (d.earnCount > 1 ? 's' : ''); } + const bc = $('dbBuyersChip'); + if (bc && d.memberId) { + bc.hidden = false; + bc.textContent = (d.buyerCount || 0) >= 5 ? 'level 3 open' : (d.buyerCount || 0) >= 2 ? 'level 2 open' : 'level 2 at 2 buyers'; + } + 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); + renderBadges(d); + loadFeatured(); + loadLineTree(d.buyerCount || 0); + loadCharts(d); + $('nextMove').textContent = nextMove(d); + renderSteps(d); + const wrap = $('rosterWrap'); + if ((d.referrals || []).length) { + $('rosterEmpty').hidden = true; + let t = wrap.querySelector('table'); + if (t) t.remove(); + t = document.createElement('table'); + t.className = 'roster'; + t.innerHTML = d.referrals.map(r => '' + String(r.name || r.email || '').replace(/[&<>]/g, '') + '' + + '' + new Date(r.joined).toLocaleDateString() + '' + + '' + r.status + '').join(''); + wrap.appendChild(t); + } + // overview recent-activity widget: chain events + line joins, newest first + try { + const c = await IAP.getConfig(); + const ov = $('ovFeed'); + const rows = []; + if (d.memberId) { + const a = await (await fetch('/api/my/activity')).json(); + for (const ev of [...(a.earnings || []), ...(a.purchases || [])] + .sort((x, y) => y.block - x.block).slice(0, 5)) rows.push(IAP.feedRow(ev, c)); + } + for (const r of (d.referrals || []).slice(0, 3)) { + const div = document.createElement('div'); + div.className = 'row'; + div.innerHTML = '🤝 ' + esc(r.name || r.email || 'A new member') + ' joined your line' + + new Date(r.joined).toLocaleDateString() + ''; + rows.push(div); + } + if (rows.length) { + ov.innerHTML = ''; + rows.slice(0, 6).forEach(r => ov.appendChild(r)); + } + } catch (e) {} + // ready-to-send share message + promo tools, personalized + const link = location.origin + '/join/' + (d.username || d.refCode || d.memberId || ''); + fillPromo(link, d); + // founding-week readiness: shown until every item is done (or the launch moment is a week past) + try { + const cfg = await IAP.getConfig(); const items = IAP.launchChecks(d); const done = items.filter(i => i.done).length; + const at = cfg.launchAt ? new Date(cfg.launchAt).getTime() : 0; + const show = done < items.length && !(at && Date.now() > at + 7 * 86400000); + const lm = $('launchMark'); + if (lm) { lm.hidden = !show; lm.innerHTML = show ? 'Launch ready: ' + done + ' of ' + items.length + '. ' + (at && Date.now() < at ? 'Doors open ' + new Date(at).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' }) + '. ' : '') + 'Open the founding-week checklist' : ''; } + } catch (e) {} + // people waiting for a sponsor in the holding tank (Marty, 2026-09-12): every Overview sees it + try { + const tw = d.tankWaiting, tn = $('tankNotice'); + if (tn) { + tn.hidden = !(tw && tw.count); + if (tw && tw.count) tn.innerHTML = '' + tw.count + (tw.count === 1 ? ' person is' : ' people are') + ' waiting for a sponsor in the holding tank \u00b7 ' + + tw.names.map(esc).join(', ') + (tw.count > tw.names.length ? ' and more' : '') + '. Adopt them from My line' + + (tw.eligible ? '.' : ' (you need your own $20 package first).'); + } + } catch (e) {} + if (d.username) { // wall link rides the username + const wl = location.origin + '/wall/' + d.username; + $('wallLine').textContent = wl; + if ($('promoWallStrip')) { // the same wall link at the top of Promo tools, next to the invite link + $('promoWallStrip').hidden = false; $('promoWallLink').textContent = wl; $('promoWallOpen').href = '/wall/' + d.username; + $('promoWallCopy').onclick = async () => { try { await navigator.clipboard.writeText(wl); IAP.status('Wall link copied.', 'ok'); } catch (e) { IAP.status('Copy failed. Select the link and copy it.', 'bad'); } }; + } + $('wallCopy').hidden = false; + $('wallOpen').hidden = false; + $('wallOpen').href = '/wall/' + d.username; + $('wallCopy').onclick = async () => { + try { await navigator.clipboard.writeText(wl); IAP.status('Wall link copied.', 'ok'); } + catch (e) { IAP.status('Copy failed. Select the link text instead.', 'bad'); } + }; + } + if (d.refCode || d.memberId) { + const pitch = 'I found an advertising site that pays referrals instantly to your own wallet. ' + + 'No withdrawals, no waiting, and every payment is public on a blockchain ledger you can check yourself. ' + + 'Free to join and look around: ' + link; + $('copyPitch').hidden = false; + $('pitchPreview').hidden = false; + $('pitchPreview').textContent = '"' + pitch + '"'; + $('copyPitch').onclick = async () => { + try { await navigator.clipboard.writeText(pitch); IAP.status('Message copied. Paste it anywhere.', 'ok'); } + catch (e) { IAP.status('Copy failed. Select the preview text instead.', 'bad'); } + }; + } + } catch (e) {} + } + + // ── back-office menu: hash-routed panes ─────────────── + const PANES = ['overview', 'line', 'pipeline', 'rotator', 'buy', 'campaigns', 'earn', 'earnings', 'promo', 'training', 'wallet', 'profile']; + const TITLES = { overview: 'Overview', line: 'My line', pipeline: 'Pipeline', rotator: 'Rotator', buy: 'Buy packages', campaigns: 'Campaigns', + earn: 'Earn credits', earnings: 'Earnings', promo: 'Promo tools', training: 'Training', + wallet: 'Wallet & account', profile: 'Profile' }; + function setPane(name) { + if (!PANES.includes(name)) name = 'overview'; + for (const p of PANES) { + const el = $('pane-' + p); + if (el) el.hidden = p !== name; + } + document.querySelectorAll('.bo-menu [data-pane]').forEach(b => + b.classList.toggle('on', b.dataset.pane === name)); + if ($('boTitle')) $('boTitle').textContent = TITLES[name]; + // member ads: a fresh text ad in the strip under the title, and a banner at the foot of the pane + IAP.adSlot('text', 'adStripTop'); + if ($('adSlotPane-' + name)) IAP.adSlot('banner', 'adSlotPane-' + name); + if (name === 'earn') setEarnSub(earnSub); // refresh whichever sub-tab is active + else if (vidState.token) stopVideo(); + if (name === 'profile') loadLineBanner(); + if (name === 'line') { loadLineage(); loadUplineMessages(); loadCoach(); loadLinkStats(); loadProspects(); } + if (name === 'campaigns') ['cTarget', 'cImage', 'cVideoUrl'].forEach(id => { if ($(id)) $(id).value = ''; }); // no residual URL between visits + if (name === 'training') loadTraining(); + if (name === 'pipeline') loadPipeline(); + if (name === 'rotator') loadRotator(); + document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer + if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name); + } + document.querySelectorAll('.bo-menu [data-pane]').forEach(b => + b.addEventListener('click', () => setPane(b.dataset.pane))); + document.querySelectorAll('.qa [data-goto]').forEach(b => + b.addEventListener('click', () => setPane(b.dataset.goto))); + const qaCopy = document.getElementById('qaCopyInvite'); + if (qaCopy) qaCopy.addEventListener('click', async () => { + const link = document.getElementById('inviteLine').textContent; + if (!link || !link.startsWith('http')) { setPane('line'); return; } + try { await navigator.clipboard.writeText(link); IAP.status('Invite link copied.', 'ok'); } + catch (e) { setPane('line'); } + }); + window.addEventListener('hashchange', () => setPane(location.hash.slice(1))); + if ($('boBurger')) $('boBurger').addEventListener('click', () => + document.getElementById('memberArea').classList.toggle('side-open')); + + async function render() { + // while /api/me answers, show a spinner instead of flashing the sign-in card at a signed-in member + let me = null; + try { me = await IAP.refreshNavWallet(); } catch (e) { me = null; } + if ($('bootSpin')) $('bootSpin').hidden = true; + // one way in: email. A wallet-only session (no account) is sent back to the + // email card with a finish-setup note; verifying the code links that wallet. + const walletOnly = !!(me && me.signedIn && !me.email); + const signedIn = me && me.signedIn && !walletOnly; + $('authArea').hidden = !!signedIn; + $('memberArea').hidden = !signedIn; + if ($('mcFinish')) $('mcFinish').hidden = !walletOnly; + if (!signedIn) return; + if ($('adminLink')) $('adminLink').hidden = !me.isAdmin; // admin portal link, only for ADMIN_EMAIL + // REQUIRED first step (Marty, 2026-09-12): no username, nothing else. The modal cannot be + // skipped or dismissed; it resolves only when a username is saved, then the area renders. + if (!me.username) { + if (!render.gating) { render.gating = true; showOnboard(true).then(() => { render.gating = false; render(); }); } + return; + } + // arrived from an invite page: run the welcome tour and login ad once + if (/[?&]welcome=1/.test(location.search) && !render.welcomed) { + render.welcomed = true; + history.replaceState(null, '', '/my' + (location.hash || '')); + (async () => { + try { if (!(await showGauntlet())) await showLoginAd(); } catch (e) {} + await render(); + })(); + } + setPane(location.hash.slice(1) || 'overview'); + $('campGate').hidden = !!me.memberId; + $('earnGate').hidden = !!me.memberId; + if (me.username && !me.address) { let shown = ''; try { shown = sessionStorage.getItem('iap.gs.walletHint'); sessionStorage.setItem('iap.gs.walletHint', '1'); } catch (e) {} if (shown !== '1') setTimeout(() => IAP.status('Next step when you are ready: link your wallet so your payouts have somewhere to land. The Getting started card at the top shows how.', 'ok'), 1200); } + loadDashboard(); + + const who = []; + if (me.email) who.push(me.email); + // members kept asking whether the wallet was really connected (Marty, 2026-09-12): say it, with a green check + if (me.address) who.push('Wallet connected ' + me.address.slice(0, 8) + '…' + me.address.slice(-6) + ''); + else who.push('No wallet connected yet (link one below)'); + if (me.memberId) who.push('on-chain member #' + me.memberId + '' + + (me.onchainSponsorId ? ', sponsored by #' + me.onchainSponsorId : '')); + else if (me.sponsorId) who.push('invited by member #' + me.sponsorId); + $('posLine').innerHTML = who.join('
    '); + + $('creditLine').textContent = (me.credits || 0).toLocaleString(); + loadPositions(me); + $('linkCard').hidden = !!me.address; + $('activateCard').hidden = !(me.address && !me.memberId); + $('activityArea').hidden = !me.memberId; + $('campGate').hidden = true; // earned credits fund campaigns for everyone + $('campaignCard').hidden = false; + loadCampaigns(); + + // profile pane state + $('pfCurrent').textContent = me.username ? '@' + me.username + ' is your permanent username. Your invite link, your public page and any banners you shared carry it, so it cannot be changed.' : 'No username yet. Members see you as a number until you pick one. Choose carefully: it is permanent once saved.'; + if (!$('pfUsername').value) $('pfUsername').value = me.username || ''; + $('pfUsername').disabled = !!me.username; $('pfSaveBtn').hidden = !!me.username; + $('pfDetails').innerHTML = 'Email: ' + (me.email || 'none') + '
    Wallet: ' + + (me.address ? '' + me.address.slice(0, 10) + '…' + me.address.slice(-6) + '' : 'not linked yet') + + '
    On-chain member: ' + (me.memberId ? '#' + me.memberId : 'not yet'); + // the share link works from day one; usernames make it a vanity link + if (me.username || me.refCode || me.memberId) { + $('inviteLine').textContent = location.origin + '/join/' + (me.username || me.refCode || me.memberId); + $('copyInvite').hidden = false; + } else { + $('inviteLine').textContent = 'Sign in with your email to get your link.'; + $('copyInvite').hidden = true; + } + if (me.memberId) { + const bc = me.buyerCount || 0; + $('qualLine').innerHTML = '' + bc + ' qualifying buyer(s) referred
    ' + + (bc >= 5 ? 'Level 3 unlocked: full three-level earnings' + : bc >= 2 ? 'Level 2 unlocked · ' + (5 - bc) + ' more for level 3' + : (2 - bc) + ' more buyer(s) of $20+ unlock level 2'); + loadActivity(); + } else { + $('qualLine').innerHTML = 'Share your link now. Then switch on payouts (free, above) ' + + 'before your people start buying: the contract locks each buyer to their sponsor at ' + + 'their first purchase, and payments only route to wallets that are switched on.'; + } + } + + async function loadActivity() { + try { + const c = await IAP.getConfig(); + const a = await (await fetch('/api/my/activity')).json(); + const fill = (id, evs, empty) => { + const el = $(id); + el.innerHTML = ''; + if (!evs || !evs.length) { el.innerHTML = '
    ' + empty + '
    '; return; } + for (const ev of evs) el.appendChild(IAP.feedRow(ev, c)); + }; + fill('earnFeed', a.earnings, 'No payouts yet. They appear here the moment one lands.'); + fill('refFeed', a.referrals, 'No referral activity yet. Share your invite link.'); + fill('buyFeed', a.purchases, 'No purchases from your wallet yet.'); + } catch (e) {} + } + + async function loadCampaigns() { + try { + const r = await (await fetch('/api/my/campaigns')).json(); + if (r.error) return; + lastRates = r.rates; + if ($('cGeoHint') && r.tiers) $('cGeoHint').textContent = 'All three ticked = everyone. Tier 1: ' + r.tiers.t1.join(', ') + '. Tier 2: ' + r.tiers.t2.join(', ') + '. Tier 3: every other country. Geo applies to delivery on this site; a narrowed banner or text ad is kept off the worldwide partner network. ' + (r.geoReady ? '' : 'Country data is still loading, so narrowed campaigns pause until it is ready. ') + 'IP geolocation by DB-IP.'; + // populate the banner-size dropdown once (ids map to NAS width/height) + if (r.bannerSizes && $('cSize') && !$('cSize').options.length) + $('cSize').innerHTML = r.bannerSizes.map(s => '').join(''); + applyType(); // the default type is banner: show its size + image rows now that the sizes exist + soloHint(); + if ($('spendBanner')) { + $('spendBanner').hidden = false; + $('spendBig').textContent = r.availableCredits.toLocaleString(); + $('spendSub').textContent = '= $' + (r.availableCredits / 100).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' of ad delivery · ' + r.purchasedCredits.toLocaleString() + ' purchased' + (r.creditedCredits ? ' (' + r.creditedCredits.toLocaleString() + ' of it credited to you, spends on anything)' : '') + ' + ' + (r.earnedCredits || 0).toLocaleString() + ' earned' + (r.positionCount > 1 ? ' · pooled across ' + r.positionCount + ' positions (largest single position ' + (r.largestPosition || 0).toLocaleString() + ')' : ''); + if ($('spendNote')) $('spendNote').textContent = r.inCampaigns + ? 'Not counting ' + r.inCampaigns.toLocaleString() + ' credits already set aside for your live campaigns. That budget spends down inside each campaign below. This number only moves when you start, top up or pause a campaign.' + : 'This is what is not committed to a campaign. When you start one, its budget moves out of here and spends down inside the campaign.'; + $('spendRates').innerHTML = [['Banner', r.rates.bannerCreditsPerBatch + ' cr / ' + r.rates.bannerBatch + ' views'], ['Text', r.rates.textCreditsPerBatch + ' cr / ' + r.rates.textBatch + ' views'], ['Login', r.rates.loginCreditsPerDay + ' cr / day'], ['Solo', r.rates.soloCostPerRecipient + ' cr / delivery'], ['Featured', (r.rates.featuredPerDay || 40) + ' cr / day'], ['Visit', (r.rates.visitCostPerVisit || 3) + ' cr / visit']].map(x => '' + x[0] + ' ' + x[1] + '').join(''); + } + $('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString() + + (r.earnedCredits ? ' (' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits + ' earned)' : '') + + (r.inCampaigns ? ' · ' + r.inCampaigns.toLocaleString() + ' set aside in live campaigns' : '') + + ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch + + ' views, text ' + r.rates.textCreditsPerBatch + 'cr/' + r.rates.textBatch + + ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day, solo ' + + (r.rates.soloCostPerRecipient || 5) + 'cr/delivery'; + const el = $('campList'); + el.innerHTML = ''; + if (!r.campaigns.length) { el.innerHTML = '

    No campaigns yet. Launch your first below.

    '; return; } + const tbl = document.createElement('div'); + tbl.className = 'tablewrap'; + tbl.innerHTML = '' + + '' + + r.campaigns.map(c => '' + + '' + + '' + + '' + + '' + + '' + + '' + + '').join('') + '
    NameTypeViews hereNetwork viewsClicksSpentBudgetStatus
    ' + c.name + '' + hourBars(r.hours && r.hours[c.id]) + geoLine(r.geo && r.geo[c.id]) + '' + c.type + (c.type === 'banner' && c.width ? ' ' + c.width + '×' + c.height + '' : '') + (c.dailyCap ? ' cap ' + c.dailyCap + '/day' : '') + (c.geo ? ' tier ' + esc(c.geo.replace(/,/g, '+')) + '' : '') + schedChips(c) + '' + c.imps.toLocaleString() + '' + (['banner', 'text'].includes(c.type) ? (c.impsNas || 0).toLocaleString() : 'n/a') + '' + c.clicks + (r.clickSources && r.clickSources[c.id] ? '
    ' + Object.entries(r.clickSources[c.id]).sort((a, b) => b[1] - a[1]).map(([k, v]) => '' + esc(k) + ' ' + v + '').join('') + (c.impsNas ? ' · network: see Network views' : '') + '
    ' : '') + '
    ' + c.spent + '' + c.budget + '' + (c.status === 'out' ? 'budget spent' : c.status === 'done' ? 'ended' : c.scheduled ? 'scheduled' : c.status) + '' + (c.status === 'active' ? '' + : c.status === 'paused' ? '' : '') + + ' ' + + '
    '; + el.appendChild(tbl); + el.querySelectorAll('button[data-camp]').forEach(b => b.addEventListener('click', async () => { + try { await api('/api/my/campaigns/' + b.dataset.camp + '/' + b.dataset.act); await loadCampaigns(); } + catch (e) { IAP.status(e.message, 'bad'); } + })); + el.querySelectorAll('button[data-topup]').forEach(b => b.addEventListener('click', async () => { + const n = await IAP.ask({ title: 'Add credits', text: 'How many credits to add to this campaign? More credits buy more views.', type: 'number', placeholder: 'e.g. 100', ok: 'Add credits' }); + if (!n) return; + try { const r = await api('/api/my/campaigns/' + b.dataset.topup + '/topup', { credits: Number(n) }); + IAP.status('Added ' + r.added + ' credits' + (r.reactivated ? ' — campaign is live again.' : '.'), 'ok'); + await loadCampaigns(); loadDashboard(); + } catch (e) { IAP.status(e.message, 'bad'); } + })); + } catch (e) {} + } + let lastRates = null; + function soloHint() { + if (!lastRates || $('cType').value !== 'solo') return; + const cost = lastRates.soloCostPerRecipient || 5; + const n = Math.floor((Number($('cBudget').value) || 0) / cost); + $('cSoloHint').textContent = cost + ' credits per guaranteed inbox delivery' + + (n ? ' — this budget reaches ' + n + ' members' : '') + + '. Readers earn ' + (lastRates.soloReadCredits || 2) + ' credits for a real read, so your message gets opened.'; + } + $('cBudget').addEventListener('input', soloHint); + // rich solo editor: small toolbar over contenteditable (CSP allows no external editor); + // the server whitelist-sanitizes whatever HTML arrives, this is just authoring comfort + document.querySelectorAll('.ed-bar [data-cmd]').forEach(btn => + btn.addEventListener('click', () => { $('cSoloEd').focus(); document.execCommand(btn.dataset.cmd, false, null); })); + document.querySelectorAll('.ed-bar [data-block]').forEach(btn => + btn.addEventListener('click', () => { $('cSoloEd').focus(); document.execCommand('formatBlock', false, btn.dataset.block); })); + $('edLinkBtn').addEventListener('click', async () => { + const sel = window.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null; // the dialog steals the selection + const url = await IAP.ask({ title: 'Insert link', text: 'Link URL (https://…)', placeholder: 'https://', ok: 'Insert' }); + if (!url) return; + $('cSoloEd').focus(); if (range) { sel.removeAllRanges(); sel.addRange(range); } + document.execCommand('createLink', false, url); + }); + // inline media: upload, then drop the element at the cursor (BV-style) + let mediaMode = 'image'; + function insertHtmlAtCursor(html) { + const ed = $('cSoloEd'); + ed.focus(); + if (!document.execCommand('insertHTML', false, html)) ed.insertAdjacentHTML('beforeend', html); + } + $('edImgBtn').addEventListener('click', () => { mediaMode = 'image'; $('cSoloFile').accept = 'image/png,image/jpeg,image/webp,image/gif'; $('cSoloFile').click(); }); + $('edVidBtn').addEventListener('click', () => { mediaMode = 'video'; $('cSoloFile').accept = 'video/mp4,video/webm'; $('cSoloFile').click(); }); + $('cSoloFile').addEventListener('change', async () => { + const f = $('cSoloFile').files[0]; + if (!f) return; + $('edMediaInfo').textContent = 'Uploading ' + f.name + '…'; + try { + const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json(); + if (r.error) { $('edMediaInfo').textContent = r.error; $('cSoloFile').value = ''; return; } + insertHtmlAtCursor(r.type === 'video' + ? '


    ' + : '


    '); + $('edMediaInfo').textContent = f.name + ' inserted'; + } catch (e) { $('edMediaInfo').textContent = 'Upload failed. Try again.'; } + $('cSoloFile').value = ''; + }); + // raw-text toggle: swap the WYSIWYG surface for the underlying HTML and back + $('edRawBtn').addEventListener('click', () => { + const ed = $('cSoloEd'), raw = $('cSoloRaw'); + if (raw.hidden) { raw.value = ed.innerHTML; raw.hidden = false; ed.hidden = true; $('edRawBtn').textContent = 'Visual'; } + else { ed.innerHTML = raw.value; ed.hidden = false; raw.hidden = true; $('edRawBtn').textContent = 'Raw text'; } + }); + const WHERE = { banner: 'Runs in the ad viewer, on the home page, the live ledger, every Overview, the sidebar tile, and out in the Network Ad Space rotation across the wider network.', + text: 'Runs in the ad viewer, the live ledger text slot, and out in the Network Ad Space rotation.', + login: 'Full screen for every member who signs in, ten seconds, once a day per member. Opens in a fresh tab, so any working page qualifies. Login ads spend purchased credits only.', + solo: 'Delivered into member inboxes under Earn credits. Each read is timed and rewarded, so it gets opened.', + video: 'Plays in Watch videos and the Shorts feed under Earn credits. You pay only for completed watches.', + featured: 'Your headline and link in the Featured strip on every member Overview for the days you book.', + visits: 'A distinct member opens your site in a new tab, stays eight seconds and passes a check. Nobody counts twice.' }; + const whereHint = () => { const el = $('cWhere'); if (el) el.textContent = WHERE[$('cType').value] || ''; }; + whereHint(); + // runs on every type change AND once at load, so the default type (banner) shows its size + image rows immediately + function applyType() { // hoisted: loadCampaigns may run before this line is reached + whereHint(); + const t = $('cType').value; + $('cImageRow').hidden = t !== 'banner'; // only banners carry a creative; login frames its URL + $('cSizeRow').hidden = t !== 'banner'; + $('cTitleRow').hidden = t !== 'text' && t !== 'solo'; + $('cBodyRow').hidden = t !== 'text'; + $('cSoloRow').hidden = t !== 'solo'; + $('cSoloHint').hidden = t !== 'solo'; + $('cVideoRow').hidden = t !== 'video'; + $('cFeaturedRow').hidden = t !== 'featured'; + $('cVisitsRow').hidden = t !== 'visits'; + if ($('cSchedRow')) { $('cSchedRow').hidden = t === 'featured'; $('cStartLbl').textContent = t === 'solo' ? 'Send from (optional)' : 'Start (optional)'; + $('cSchedHint').textContent = t === 'solo' ? 'Deliveries to member inboxes begin at the time you pick, so you can land when people are reading. Leave empty to start now.' : 'Leave both empty to start now and run until the budget is spent. Times are your local time. Anything left when a campaign ends goes back to Available.'; } + // fixed-cost types derive their spend (featured = day slots, visits = flat pack), + // so hide the free-form Budget field for them to avoid confusion + $('cBudgetRow').hidden = (t === 'featured' || t === 'visits'); + if ($('cCapRow')) $('cCapRow').hidden = !(t === 'banner' || t === 'text'); + // solo ads have a real floor (5cr x 10 deliveries): default to 50 so 10 isn't rejected + if (t === 'solo' && (!$('cBudget').value || Number($('cBudget').value) < 50)) $('cBudget').value = (lastRates && lastRates.soloCostPerRecipient ? lastRates.soloCostPerRecipient : 5) * 10; + if (t === 'visits') visitHint(); + $('cTitle').placeholder = t === 'solo' ? 'Subject line (max 80)' : 'Headline (max 60)'; + soloHint(); + if (t === 'video') videoHint(); + if (t === 'featured') featHint(); + } + $('cType').addEventListener('change', applyType); + let featStartDay = 0; // selected start-day offset (0 = today) + async function featHint() { + if (!lastRates || !lastRates.featuredDurations) return; + if ($('cFeatDays') && !$('cFeatDays').options.length) + $('cFeatDays').innerHTML = lastRates.featuredDurations.map(dys => + '').join(''); + let s = null; + try { s = await (await fetch('/api/featured/stats')).json(); } catch (e) {} + if (s && s.occupancy) { + const grid = $('cFeatDaysGrid'); + grid.innerHTML = s.occupancy.map(d => { + const label = d.offset === 0 ? 'Today' : d.offset === 1 ? 'Tomorrow' : new Date(d.day + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', month: 'numeric', day: 'numeric' }); + const full = d.open <= 0; + return '
    ' + + '
    ' + label + '
    ' + d.count + '/' + d.cap + (full ? ' full' : ' left ' + d.open) + '
    '; + }).join(''); + grid.querySelectorAll('.feat-day:not(.full)').forEach(el => + el.addEventListener('click', () => { featStartDay = Number(el.dataset.off); featHint(); })); + } + const dys = Number($('cFeatDays').value) || (lastRates.featuredDurations[0]); + const dayLabel = featStartDay === 0 ? 'today' : featStartDay === 1 ? 'tomorrow' : 'in ' + featStartDay + ' days'; + $('cFeatHint').textContent = 'Runs ' + dys + ' day' + (dys > 1 ? 's' : '') + ' starting ' + dayLabel + + ' for ' + (dys * lastRates.featuredPerDay) + ' credits. Max ' + (s ? s.slotsPerDay : 10) + ' links share any day.'; + } + document.addEventListener('change', e => { if (e.target && e.target.id === 'cFeatDays') featHint(); }); + function visitHint() { + if (!lastRates) return; + const n = Number($('cVisitCount').value) || 0; + const cost = n * (lastRates.visitCostPerVisit || 3); + $('cVisitHint').textContent = (lastRates.visitCostPerVisit || 3) + ' credits per verified visit' + + (n >= (lastRates.visitMinPack || 20) ? ' — ' + n + ' visits = ' + cost + ' credits' : ' (min ' + (lastRates.visitMinPack || 20) + ')') + + '. Each is a unique member, dwell + human-check verified.'; + } + $('cVisitCount').addEventListener('input', visitHint); + // video composer: tier dropdown + upload + price hint + function videoHint() { + if (!lastRates || !lastRates.videoTiers) return; + if ($('cWatchSecs') && !$('cWatchSecs').options.length) + $('cWatchSecs').innerHTML = lastRates.videoTiers.map(t => + '').join(''); + const tier = lastRates.videoTiers.find(t => t.secs === Number($('cWatchSecs').value)) || lastRates.videoTiers[0]; + const n = tier ? Math.floor((Number($('cBudget').value) || 0) / tier.cost) : 0; + $('cVideoHint').textContent = tier ? (tier.cost + ' credits per completed ' + tier.secs + 's view' + + (n ? ' — this budget buys ' + n + ' views' : '') + '. Viewers earn ' + tier.reward + ' credits each, so they watch.') : ''; + } + document.addEventListener('change', e => { if (e.target && e.target.id === 'cWatchSecs') videoHint(); }); + $('cBudget').addEventListener('input', () => { if ($('cType').value === 'video') videoHint(); }); + $('cVideoUploadBtn').addEventListener('click', () => $('cVideoFile').click()); + $('cVideoUrl').addEventListener('change', async () => { + const url = $('cVideoUrl').value.trim(); + $('cVideoInfo').textContent = url ? 'checking video…' : ''; + cVidDims = url ? await probeVideoDims(url) : null; + if (url && !cVidDims) $('cVideoInfo').textContent = 'could not read that video'; + else { $('cVideoInfo').textContent = ''; showVidOrient(); } + }); + $('cVideoFile').addEventListener('change', async () => { + const f = $('cVideoFile').files[0]; + if (!f) return; + $('cVideoInfo').textContent = 'Uploading ' + f.name + '… (large files take a moment)'; + try { + const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json(); + if (r.error) { $('cVideoInfo').textContent = r.error; $('cVideoFile').value = ''; return; } + $('cVideoUrl').value = r.url; + $('cVideoInfo').textContent = f.name + ' uploaded'; + $('cVideoPrev').hidden = false; + $('cVideoPrev').innerHTML = ''; + cVidDims = await probeVideoDims(r.url); showVidOrient(); + } catch (e) { $('cVideoInfo').textContent = 'Upload failed. Try again.'; } + $('cVideoFile').value = ''; + }); + $('createCampBtn').addEventListener('click', busy2($('createCampBtn'), async () => { + // in raw mode the source of truth is the textarea; sync it back first + let soloBody = $('cSoloEd').innerHTML; + if ($('cSoloRaw') && !$('cSoloRaw').hidden) soloBody = $('cSoloRaw').value; + const t = $('cType').value; + const isVideo = t === 'video', isFeat = t === 'featured'; + if (isVideo && !cVidDims && $('cVideoUrl').value) cVidDims = await probeVideoDims($('cVideoUrl').value); + await api('/api/my/campaigns', { type: t, name: $('cName').value, + targetUrl: $('cTarget').value, imageUrl: $('cImage').value, size: $('cSize').value, + title: isVideo ? $('cVideoTitle').value : isFeat ? $('cFeatTitle').value : t === 'visits' ? $('cVisitTitle').value : $('cTitle').value, + body: t === 'solo' ? soloBody : $('cBody').value, + videoUrl: $('cVideoUrl').value, watchSecs: Number($('cWatchSecs').value), + videoW: cVidDims ? cVidDims.w : null, videoH: cVidDims ? cVidDims.h : null, + days: Number($('cFeatDays').value), startDay: featStartDay, + count: Number($('cVisitCount').value), + dailyCap: ($('cDailyCap') && (t === 'banner' || t === 'text')) ? Number($('cDailyCap').value) || 0 : 0, + geo: [...document.querySelectorAll('.geoTier:checked')].map(x => x.value).join(','), + startsAt: ($('cStartAt') && $('cStartAt').value && !isFeat) ? new Date($('cStartAt').value).getTime() : 0, + endsAt: ($('cEndAt') && $('cEndAt').value && !isFeat) ? new Date($('cEndAt').value).getTime() : 0, + ctaLabel: isVideo ? $('cVideoCta').value : $('cCtaLabel').value, + budget: isFeat ? (Number($('cFeatDays').value) * (lastRates.featuredPerDay || 40)) : t === 'visits' ? (Number($('cVisitCount').value) * (lastRates.visitCostPerVisit || 3)) : Number($('cBudget').value) }); + const schedStart = ($('cStartAt') && $('cStartAt').value && !isFeat) ? new Date($('cStartAt').value) : null; + IAP.status(schedStart && schedStart.getTime() > Date.now() ? 'Campaign saved. It starts serving ' + schedStart.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + '.' : 'Campaign is live. It starts serving right away.', 'ok'); + // clear EVERY field so no target/creative carries into the next campaign + ['cName', 'cBudget', 'cTarget', 'cImage', 'cTitle', 'cBody', 'cCtaLabel', + 'cVideoUrl', 'cVideoTitle', 'cVideoCta', 'cVisitTitle', 'cVisitCount', 'cFeatTitle', 'cDailyCap', 'cStartAt', 'cEndAt'] + .forEach(id => { if ($(id)) $(id).value = ''; }); + document.querySelectorAll('.geoTier').forEach(x => { x.checked = true; }); + $('cSoloEd').innerHTML = ''; if ($('cSoloRaw')) $('cSoloRaw').value = ''; + $('cVideoInfo').textContent = ''; $('cVideoPrev').hidden = true; $('cVideoPrev').innerHTML = ''; + if ($('edMediaInfo')) $('edMediaInfo').textContent = ''; + cVidDims = null; + await loadCampaigns(); + })); + // defers the busy() lookup to click time (busy is declared below) + function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); } + + // ── verified visits: open a member's site (new tab), dwell, human-check, earn ── + const visState = { token: null, id: null, dwell: 8 }; + async function loadVisitStatus() { + try { + const st = await (await fetch('/api/my/visits')).json(); + if (st.error) return; + $('vsProgress').textContent = 'today: ' + (st.status.count || 0) + ' / ' + st.status.cap + ' verified visits'; + $('vsStartBtn').hidden = st.status.count >= st.status.cap; + if (st.status.count >= st.status.cap) $('vsBox').innerHTML = 'That\'s today\'s visits. Come back tomorrow.'; + } catch (e) {} + } + async function loadVisit() { + $('vsCheck').hidden = true; $('vsVisit').hidden = true; $('vsHint').textContent = ''; + let r = null; + try { r = await (await fetch('/api/my/visits')).json(); } catch (e) {} + if (!r || !r.ad) { + $('vsBox').innerHTML = '' + (r && r.status && r.status.count >= r.status.cap + ? 'That\'s today\'s visits. Come back tomorrow.' : 'No verified-visit packs are running right now. Check back soon.') + ''; + return; + } + visState.token = r.token; visState.id = r.ad.id; visState.dwell = r.status.dwell || 8; + $('vsBox').innerHTML = '' + esc(r.ad.title || 'Member site') + '
    Open the site and stay ' + visState.dwell + 's.'; + const visit = $('vsVisit'); + visit.href = r.ad.url; visit.hidden = false; + $('vsStartBtn').textContent = 'Loading…'; $('vsStartBtn').disabled = true; + // opening the site starts the dwell; then we ask the human-check + visit.onclick = () => { + let left = visState.dwell; + $('vsHint').textContent = 'Counting your visit: ' + left + 's'; + const t = setInterval(async () => { + left--; + if (left > 0) { $('vsHint').textContent = 'Counting your visit: ' + left + 's'; return; } + clearInterval(t); + $('vsHint').textContent = 'One quick check to count the visit:'; + let c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json(); + if (c.early) { await new Promise(r2 => setTimeout(r2, (c.wait || 1) * 1000 + 300)); c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json(); } + if (c.error) { $('vsHint').textContent = c.error; return; } + renderVisitCheck(c); + }, 1000); + }; + $('vsStartBtn').textContent = 'Next visit'; $('vsStartBtn').disabled = false; + } + function renderVisitCheck(c) { + $('vsPrompt').textContent = 'Click the ' + c.prompt + ':'; + const w = $('vsOpts'); w.innerHTML = ''; + c.options.forEach((em, i) => { + const b = document.createElement('button'); + b.className = 'btn small sec'; b.style.marginRight = '6px'; b.textContent = em; + b.addEventListener('click', () => answerVisit(i)); + w.appendChild(b); + }); + $('vsCheck').hidden = false; + } + async function answerVisit(i) { + const r = await (await fetch('/api/my/visitdone', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: visState.token, answer: i }) })).json(); + if (r.error) { + if (r.retry) { const c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json(); if (!c.error) return renderVisitCheck(c); } + $('vsHint').textContent = r.error; $('vsCheck').hidden = true; return; + } + $('vsCheck').hidden = true; + $('vsHint').textContent = '+' + r.credited + ' credits — visit counted. Load the next one.'; + $('vsProgress').textContent = 'today: ' + (r.status.count || 0) + ' / ' + (r.status.cap || 0) + ' verified visits'; + IAP.status('+' + r.credited + ' credits for a verified visit.', 'ok'); + loadDashboard(); + } + $('vsStartBtn').addEventListener('click', () => loadVisit()); + + // ── watch-to-earn videos: escape-proof player, server-clock reward ── + const vidState = { token: null, secs: 0, maxSeen: 0, done: false, credited: false }; + // detected dimensions of the video being created — portrait goes to Shorts, landscape to Watch videos + let cVidDims = null; + function probeVideoDims(url) { + return new Promise(resolve => { + if (!url) return resolve(null); + const v = document.createElement('video'); v.preload = 'metadata'; v.muted = true; + let done = false; const fin = r => { if (!done) { done = true; resolve(r); } }; + v.onloadedmetadata = () => fin(v.videoWidth && v.videoHeight ? { w: v.videoWidth, h: v.videoHeight } : null); + v.onerror = () => fin(null); setTimeout(() => fin(null), 8000); v.src = url; + }); + } + function showVidOrient() { + const el = $('cVideoInfo'); if (!el) return; + if (!cVidDims) return; + const portrait = cVidDims.h > cVidDims.w; + el.textContent = (el.textContent ? el.textContent + ' · ' : '') + + (portrait ? 'portrait — shows in the Shorts reel' : 'landscape — shows in the Watch videos tab'); + } + async function loadVideoStatus() { + try { + const st = await (await fetch('/api/my/videos?orientation=landscape')).json(); + if (st.error) return; + $('vidProgress').textContent = 'today: ' + (st.status.count || 0) + ' / ' + st.status.cap + ' videos watched'; + if (st.status.left <= 0) { + $('vidBox').innerHTML = 'That is today\'s video set. Come back tomorrow.'; + $('vidStartBtn').hidden = true; + return; + } + $('vidStartBtn').hidden = false; + } catch (e) {} + } + // leaving the player (other sub-tab, other pane, page hidden) stops the clip: nothing plays or earns in the background (Marty, 2026-09-13) + function stopVideo() { + const p = $('vidPlayer'); if (!p) return; + const was = !!vidState.token && !vidState.done; + try { p.pause(); p.ontimeupdate = null; p.onseeking = null; p.removeAttribute('src'); p.load(); } catch (e) {} + vidState.token = null; vidState.done = false; vidState.credited = false; vidState.maxSeen = 0; + if ($('vidWrap')) $('vidWrap').hidden = true; + if ($('vidBox')) { $('vidBox').hidden = false; if (was) $('vidBox').innerHTML = 'Video stopped when you left the tab. Tap Load a video to start a fresh one.'; } + if ($('vidStartBtn')) { $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Load a video'; } + } + document.addEventListener('visibilitychange', () => { if (document.hidden && vidState.token && !vidState.done) stopVideo(); }); + async function loadVideoAd() { + let r = null; + try { r = await (await fetch('/api/my/videos?orientation=landscape')).json(); } catch (e) {} + if (!r || !r.ad) { + // same done screen as Watch ads when the day's videos are finished (Marty, 2026-09-13) + const capHit = !!(r && r.status && r.status.left <= 0), allWatched = !!(r && r.allWatched); + $('vidBox').innerHTML = (capHit || allWatched) + ? '
    Videos done for today' + (capHit ? 'That is today’s video set (' + r.status.count + ' watched). Fresh videos tomorrow.' : 'You have watched every live video for today; each one pays once a day. New ones appear as members launch video campaigns.') + '
    ' + : 'No member videos are live right now. Check back when a campaign is running.'; + if ($('vidStartBtn')) $('vidStartBtn').hidden = capHit || allWatched; + $('vidBox').hidden = false; + $('vidWrap').hidden = true; + return; + } + const ad = r.ad; + vidState.token = r.token; vidState.secs = ad.watchSecs; vidState.maxSeen = 0; vidState.done = false; vidState.credited = false; + $('vidBox').hidden = true; + $('vidWrap').hidden = false; + $('vidCta').hidden = true; + $('vidHint').textContent = ad.title ? 'Now playing: ' + ad.title : ''; + const p = $('vidPlayer'); + p.src = ad.videoUrl; + p.currentTime = 0; + // escape-proof: no seeking past what's been watched; track max reached + p.onseeking = () => { if (p.currentTime > vidState.maxSeen + 0.5) p.currentTime = vidState.maxSeen; }; + p.ontimeupdate = () => { + if (p.currentTime > vidState.maxSeen) vidState.maxSeen = p.currentTime; + const left = Math.max(0, Math.ceil(vidState.secs - vidState.maxSeen)); + $('vidTimer').textContent = left > 0 ? 'Watch ' + left + 's more to earn' : 'Watch time met — finishing…'; + if (!vidState.done && vidState.maxSeen >= vidState.secs) { vidState.done = true; completeVideo(ad); } + }; + $('vidStartBtn').textContent = 'Playing…'; + $('vidStartBtn').disabled = true; + try { await p.play(); } catch (e) { $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Tap to play'; } + $('vidCta').href = ad.ctaUrl; + $('vidCta').textContent = ad.ctaLabel || 'Learn more'; + $('vidCta').hidden = false; + } + async function completeVideo(ad) { + if (vidState.credited) return; + vidState.credited = true; + try { + const r = await (await fetch('/api/my/videowatch', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: vidState.token }) })).json(); + if (r.error) { IAP.status(r.error, 'bad'); $('vidHint').textContent = r.error + (/no longer open|stale/.test(r.error) ? ' Loading a fresh one.' : ''); if (/no longer open|stale/.test(r.error)) setTimeout(() => loadVideoAd(), 1200); } + else if (r.credited) { IAP.status('+' + r.credited + ' credits earned for watching.', 'ok'); $('vidHint').textContent = '+' + r.credited + ' credits earned. Load the next one.'; loadDashboard(); } + else $('vidHint').textContent = 'That video just ran out of budget — load another.'; + if (r.status) $('vidProgress').textContent = 'today: ' + (r.status.count || 0) + ' / ' + r.status.cap + ' videos watched'; + } catch (e) { IAP.status('Could not confirm that watch. Try the next one.', 'bad'); } + $('vidStartBtn').disabled = false; + $('vidStartBtn').textContent = 'Next video'; + } + $('vidStartBtn').addEventListener('click', () => loadVideoAd()); + // presence enforcement: pause the watch-to-earn video when the tab/window loses focus, + // resume when it comes back — the viewer must stay on the page for the watch to complete + document.addEventListener('visibilitychange', () => { + const p = $('vidPlayer'); if (!p || !p.src) return; + if (document.hidden) p.pause(); else if (!vidState.done) p.play().catch(() => {}); + }); + window.addEventListener('blur', () => { const p = $('vidPlayer'); if (p && p.src) p.pause(); }); + window.addEventListener('focus', () => { const p = $('vidPlayer'); if (p && p.src && !vidState.done) p.play().catch(() => {}); }); + + // ── 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) {} + }; + } + + // ── coaching: every direct's rung, stalled flag, one-click nudge ── + // ── pay it forward: send POL from the sponsor's own wallet to a downline's linked address ── + async function pif(email, name, address) { + let suggest = 25; + try { const { products } = await (await fetch('/api/catalog')).json(); const p20 = (products || []).find(p => p.priceCents === 2000); if (p20 && p20.costWei) suggest = Math.ceil(Number(p20.costWei) / 1e18) + 3; } catch (e) {} + const amt = await IAP.ask({ title: 'Pay it forward', text: 'Send POL from your wallet to ' + name + ' (' + address.slice(0, 6) + '…' + address.slice(-4) + ') for their first package.\nSuggested: the $20 package plus fees. Amount in POL:', type: 'number', value: String(suggest), ok: 'Send POL' }); + if (amt === null) return; + const pol = Number(amt); if (!(pol > 0)) { IAP.status('Enter an amount in POL.', 'bad'); return; } + try { + IAP.status('Confirm the transfer in your wallet…', 'ok'); + const wei = (BigInt(Math.round(pol * 1e6)) * 10n ** 12n).toString(); + const hash = await IAPWallet.sendPol(address, wei); + await api('/api/my/gift', { email, tx: hash, pol }); + IAP.status('Sent ' + pol + ' POL to ' + name + '. They have been told, with the proof link.', 'ok'); + playSound && playSound('chaching'); + } catch (e) { IAP.status('Transfer not sent: ' + ((e && e.message) || e), 'bad'); } + } + // ── holding tank: waiting members, adopt, my open adoptions ── + const ago = ts => { if (!ts) return 'never'; const d = Math.floor((Date.now() - ts) / 86400000); return d === 0 ? 'today' : d === 1 ? 'yesterday' : d + ' days ago'; }; + async function loadTank() { + const el = $('tankList'); if (!el) return; + try { + const r = await (await fetch('/api/my/tank')).json(); + if (r.error) { el.innerHTML = ''; return; } + $('tankCap').textContent = r.cap; $('tankTtl').textContent = r.ttlDays; + $('tankSub').textContent = r.waiting.length ? r.waiting.length + ' waiting' : 'nobody waiting right now'; + const why = $('tankWhy'); why.hidden = r.eligible; why.innerHTML = r.eligible ? '' : 'not yet ' + esc(r.reason); + $('tankMine').innerHTML = r.mine.length ? '

    Your open adoptions

    ' + r.mine.map(m => '
    ' + esc(m.name) + '' + + '' + (m.bought ? 'bought' : m.wallet ? 'wallet linked' : 'free, no wallet yet') + ' · last seen ' + ago(m.lastSeen) + '' + + '' + Math.max(0, Math.ceil((m.expires - Date.now()) / 86400000)) + ' days left' + + '' + + (m.address && !m.bought ? ' ' : '') + '
    ').join('') : ''; + $('tankMine').querySelectorAll('[data-tchat]').forEach(b => b.addEventListener('click', () => openConvo(b.dataset.tchat, b.dataset.tname))); + $('tankMine').querySelectorAll('[data-pif]').forEach(b => b.addEventListener('click', () => pif(b.dataset.pif, b.dataset.pname, b.dataset.paddr))); + if (!r.waiting.length) { el.innerHTML = '

    The tank is empty. Anyone who joins from the public site without a sponsor lands here.

    '; return; } + el.innerHTML = r.waiting.map(w => '
    ' + esc(w.name) + '' + + 'joined ' + ago(w.joined) + '' + + 'last sign-in: ' + ago(w.lastSeen) + '' + + (r.eligible ? '' : '') + '
    ').join(''); + el.querySelectorAll('[data-adopt]').forEach(b => b.addEventListener('click', async () => { + const note = await IAP.ask({ title: 'Adopt ' + b.dataset.aname, text: 'Your first message to ' + b.dataset.aname + ' (sent as a chat and an email):', type: 'textarea', ok: 'Adopt and send', value: 'Hi, I picked you up from the LinkSpin holding tank so you have a sponsor who will actually help. Reply here and I will walk you through the first three steps.' }); + if (note === null) return; + try { const rr = await api('/api/my/tank/adopt', { who: b.dataset.adopt, note }); IAP.status('You are now the sponsor for ' + rr.name + '. Chat and email sent.', 'ok'); loadTank(); loadCoach(); } + catch (e) { IAP.status(e.message, 'bad'); } + })); + } catch (e) {} + } + async function loadCoach() { + loadTank(); + try { + const r = await (await fetch('/api/my/coach')).json(); + const el = $('coachList'); if (!el || r.error) return; + const d = r.directs || []; + $('coachSummary').innerHTML = d.length ? '' + d.length + ' direct' + (d.length === 1 ? '' : 's') + ' · ' + r.stalled + ' quiet for 3+ days' + (r.stalled ? ' · start at the top' : '') : ''; + if (!d.length) { el.innerHTML = '

    No directs yet. When someone joins through your link they show up here with their next step.

    '; return; } + el.innerHTML = d.map(x => '
    ' + esc(x.name) + (x.stalled ? ' quiet ' + x.quietDays + 'd' : '') + + (x.rescue && x.rescue.unreached ? ' unreached · tank in ' + x.rescue.rescueInDays + 'd' : '') + (x.bound === false ? ' not bound to you on-chain' : '') + '' + + '' + esc(x.label) + ' → ' + esc(x.next) + '' + + 'rung ' + x.rung + '/6' + + '' + (x.buyerCount ? x.buyerCount + ' buyer' + (x.buyerCount === 1 ? '' : 's') : '') + '' + + '' + + (x.free && x.address ? ' ' : '') + + (x.free && x.rescue ? ' ' : '') + + (x.free ? ' ' : '') + '
    ').join(''); + el.querySelectorAll('[data-contacted]').forEach(b => b.addEventListener('click', async () => { + try { await api('/api/my/tank/contacted', { email: b.dataset.contacted }); IAP.status('Marked: you have contacted ' + b.dataset.cname + '.', 'ok'); loadCoach(); } + catch (e) { IAP.status(e.message, 'bad'); } + })); + el.querySelectorAll('[data-pif]').forEach(b => b.addEventListener('click', () => pif(b.dataset.pif, b.dataset.pname, b.dataset.paddr))); + el.querySelectorAll('[data-nudge]').forEach(b => b.addEventListener('click', async () => { + await openConvo(b.dataset.nudge, b.dataset.nname); + const inp = $('chatInput'); if (inp) { inp.value = b.dataset.say.replace(/\{\{name\}\}/g, b.dataset.nname.replace(/^@/, '')); inp.focus(); } + })); + el.querySelectorAll('[data-release]').forEach(b => b.addEventListener('click', async () => { + if (!(await IAP.confirmBox('Release ' + b.dataset.rname + ' to the holding tank? You stop being their sponsor and another member can adopt them.', { title: 'Release to the tank', ok: 'Release' }))) return; + try { await api('/api/my/tank/release', { email: b.dataset.release }); IAP.status(b.dataset.rname + ' is in the holding tank.', 'ok'); loadCoach(); } + catch (e) { IAP.status(e.message, 'bad'); } + })); + } catch (e) {} + } + // schedule chips on the campaign table (local time) + by-hour view bars + const fmtWhen = ms => new Date(ms).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + function schedChips(c) { + const now = Date.now(); let s = ''; + if (c.starts && c.starts > now) s += ' starts ' + fmtWhen(c.starts) + ''; + if (c.expires && c.type !== 'featured') s += ' ' + (c.expires > now ? 'ends ' : 'ended ') + fmtWhen(c.expires) + ''; + return s; + } + function geoLine(rows) { + if (!rows || !rows.length) return ''; + return '
    ' + rows.map(r => esc(r.cc) + ' ' + r.n).join(' · ') + '
    '; + } + function hourBars(utc) { + if (!utc || !utc.some(n => n)) return ''; + // rotate the 24 UTC buckets into the viewer's local hours + const local = new Array(24).fill(0); + for (let h = 0; h < 24; h++) local[new Date(Date.UTC(2000, 0, 1, h)).getHours()] += utc[h]; + const max = Math.max(...local); + const lab = h => (h % 12 || 12) + (h < 12 ? 'am' : 'pm'); + return '
    ' + local.map((n, h) => + '').join('') + + '
    views by hour · last 7 days · your time
    '; + } + // ── link stats: views, joins, buyers per angle ── + async function loadLinkStats() { + try { + const r = await (await fetch('/api/my/linkstats')).json(); + const t = $('linkStatsTable'); if (!t || r.error) return; + const rows = (r.angles || []).filter(a => a.views || a.joins || a.buyers); + if (!rows.length) { t.innerHTML = 'No views yet. Share your link and the numbers start here.'; return; } + t.innerHTML = 'HookViews (30d)Views (all)JoinedQualifying buyers' + + rows.map(a => '' + esc(a.angle === 'plain' ? 'plain link' : '?v=' + a.angle) + '' + a.views30 + '' + a.views + '' + a.joins + '' + a.buyers + '').join(''); + const st = $('linkSrcTable'); + if (st) { + const src = (r.sources || []).filter(x => x.views || x.joins); + st.innerHTML = src.length ? 'SourceViews (30d)Views (all)JoinedQualifying buyers' + + src.map(x => '' + esc(x.source) + '' + x.views30 + '' + x.views + '' + x.joins + '' + x.buyers + '').join('') + : 'Sources appear as visits arrive.'; + } + } catch (e) {} + } + // ── prospects: the member's own follow-up list ── + let PP_STATUSES = ['new', 'contacted', 'interested', 'joined', 'bought', 'not now']; + // ── Pipeline: the follow-up board (Marty, 2026-09-15). Stages come from the server; cards never move by hand ── + let PIPE = null, PIPE_KEY = null; + const pipeAgo = ts => { const d = Math.floor((Date.now() - ts) / 86400000); return d <= 0 ? 'today' : d === 1 ? 'yesterday' : d < 30 ? d + 'd ago' : new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }; + const pipeInvite = () => { const t = ($('inviteLine') && $('inviteLine').textContent) || ''; return /^https?:\/\//.test(t) ? t : ''; }; + function pipeCardHtml(c, showStage) { + const today = new Date(); today.setHours(23, 59, 59, 999); + const due = c.followUp && c.followUp <= today.getTime(); + const chips = []; + if (c.stalled) chips.push('stalled ' + c.quietDays + 'd'); + if (due) chips.push('follow up'); + else if (c.followUp) chips.push('' + new Date(c.followUp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ''); + if (c.tag) chips.push('' + esc(c.tag) + ''); + if (c.kind === 'member' && c.buyers) chips.push('' + c.buyers + ' buyer' + (c.buyers === 1 ? '' : 's') + ''); + const stage = showStage ? (PIPE.stages.find(s => s.key === c.stage) || {}).label || '' : ''; + return '
    ' + + '
    ' + esc(c.name) + '' + (c.kind === 'prospect' ? 'prospect' : (c.lastSeen ? 'seen ' + pipeAgo(c.lastSeen) : 'never signed in')) + '
    ' + + '
    ' + esc(stage ? stage + ' · ' + c.next : c.next) + '
    ' + + (c.note ? '
    ' + esc(c.note) + '
    ' : '') + + (chips.length ? '
    ' + chips.join('') + '
    ' : '') + '
    '; + } + async function loadPipeline() { + try { + const r = await (await fetch('/api/my/pipeline')).json(); + if (r.error) { IAP.status(r.error, 'bad'); return; } + if (!r.live) { + $('pipeSoon').hidden = false; $('pipeLive').hidden = true; + $('pipeSoonEta').textContent = r.eta ? 'Opens ' + r.eta + '. It is on the roadmap so you can see what is coming and when.' : 'It is on the roadmap so you can see what is coming and when.'; + return; + } + PIPE = r; $('pipeSoon').hidden = true; $('pipeLive').hidden = false; + const badge = $('pipeBadge'); if (badge) { const n = r.counts.due + r.counts.stalled; badge.hidden = !n; badge.textContent = n > 9 ? '9+' : n; } + $('pipeDueCount').hidden = !r.due.length; $('pipeDueCount').textContent = r.due.length; + $('pipeDue').innerHTML = r.due.length ? '
    ' + r.due.map(c => pipeCardHtml(c, true)).join('') + '
    ' : '

    Nothing due. Set a follow-up date on any card and it shows up here.

    '; + $('pipeSummary').textContent = r.counts.total ? r.counts.total + ' people on your board' + (r.counts.stalled ? ', ' + r.counts.stalled + ' stalled' : '') + '. Cards move on their own when something happens on the ledger; open one to add a note, a follow-up date or a tag.' : 'Nobody on your board yet. Add prospects on My line, and everyone who joins through your link appears here on their own.'; + $('pipeBoard').innerHTML = r.columns.map(col => '

    ' + esc(col.label) + '' + col.cards.length + '

    ' + esc(col.hint) + '

    ' + col.cards.map(c => pipeCardHtml(c, false)).join('') + '
    ').join(''); + $('pipeLive').querySelectorAll('[data-pk]').forEach(el => { + el.addEventListener('click', () => openPipeCard(el.dataset.pk)); + el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPipeCard(el.dataset.pk); } }); + }); + if (PIPE_KEY) { const c = allPipeCards().find(x => x.key === PIPE_KEY); if (c) fillPipeCard(c); else { PIPE_KEY = null; $('pipeCard').hidden = true; } } + } catch (e) { IAP.status('Could not load your pipeline.', 'bad'); } + } + function allPipeCards() { return PIPE ? PIPE.columns.flatMap(c => c.cards) : []; } + function fillPipeCard(c) { + $('pipeCardName').textContent = c.name; + const stage = (PIPE.stages.find(s => s.key === c.stage) || {}).label || ''; + $('pipeCardMeta').textContent = stage + (c.kind === 'member' ? ' · joined ' + new Date(c.since).toLocaleDateString() + (c.lastSeen ? ' · last seen ' + pipeAgo(c.lastSeen) : ' · never signed in') + (c.bought ? ' · your qualifying buyer' : '') + (c.buyersPositions ? ' · ' + c.buyers + ' buyers: ' + c.buyersMain + ' on the main wallet, ' + c.buyersPositions + ' on linked positions' : '') : ' · prospect' + (c.contact ? ' · ' + c.contact : '')); + $('pipeCardNext').innerHTML = 'Next for them: ' + esc(c.next) + (c.stalled ? ' quiet ' + c.quietDays + ' days' : ''); + const say = (c.say || '').replace('{{link}}', pipeInvite()); + $('pipeSayBlock').hidden = !say; $('pipeSay').textContent = say; + $('pipeSend').hidden = c.kind !== 'member' || !c.email; + $('pipeFollow').value = c.followUp ? new Date(c.followUp).toISOString().slice(0, 10) : ''; + const sel = $('pipeTag'); sel.innerHTML = PIPE.tags.map(t => '').join(''); + $('pipeNote').value = c.note || ''; $('pipeSaved').textContent = ''; + $('pipeCard').hidden = false; + } + function openPipeCard(key) { + const c = allPipeCards().find(x => x.key === key); if (!c) return; + PIPE_KEY = key; $('pipeLive').querySelectorAll('[data-pk]').forEach(el => el.classList.toggle('on', el.dataset.pk === key)); + fillPipeCard(c); $('pipeCard').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + if ($('pipeCardClose')) $('pipeCardClose').addEventListener('click', () => { PIPE_KEY = null; $('pipeCard').hidden = true; $('pipeLive').querySelectorAll('[data-pk]').forEach(el => el.classList.remove('on')); }); + if ($('pipeSave')) $('pipeSave').addEventListener('click', async () => { + if (!PIPE_KEY) return; + try { await api('/api/my/pipeline/note', { key: PIPE_KEY, note: $('pipeNote').value, followUp: $('pipeFollow').value || null, tag: $('pipeTag').value }); $('pipeSaved').textContent = 'Saved.'; loadPipeline(); } + catch (e) { IAP.status(e.message, 'bad'); } + }); + if ($('pipeCopy')) $('pipeCopy').addEventListener('click', async () => { try { await navigator.clipboard.writeText($('pipeSay').textContent); $('pipeCopy').textContent = 'Copied'; setTimeout(() => { $('pipeCopy').textContent = 'Copy message'; }, 1500); } catch (e) { IAP.status('Copy failed; select the text by hand.', 'bad'); } }); + if ($('pipeSend')) $('pipeSend').addEventListener('click', () => { + const c = allPipeCards().find(x => x.key === PIPE_KEY); if (!c || !c.email) return; + openConvo(c.email, c.name); + const ta = $('chatInput'); if (ta) { ta.value = $('pipeSay').textContent; ta.dispatchEvent(new Event('input')); ta.focus(); } + }); + // ── the rotator (LinkSpin) ── + let ROT = null, ROT_HOSTS = [], ROT_ID = null; + const rotLink = r => 'https://' + (ROT_HOSTS[0] || location.host) + '/r/' + r.code; + async function loadRotator() { + try { + const r = await (await fetch('/api/my/rotations')).json(); if (r.error) { IAP.status(r.error, 'bad'); return; } + ROT = r.rotations || []; ROT_HOSTS = r.hosts || []; + const el = $('rotList'); + el.innerHTML = ROT.length ? ROT.map(x => '
    ' + esc(x.name) + (x.paused ? ' paused' : '') + '' + esc(rotLink(x)) + '' + x.destinations.length + ' dest' + x.hits7 + ' hits · 7d' + x.uniques + ' uniques
    ').join('') : '

    No rotations yet. Create one above, add two or more destinations, and share the short link.

    '; + el.querySelectorAll('[data-rot]').forEach(row => row.addEventListener('click', () => openRotation(Number(row.dataset.rot)))); + if (ROT_ID) { const cur = ROT.find(x => x.id === ROT_ID); if (cur) fillRotation(cur); else { ROT_ID = null; $('rotCard').hidden = true; } } + } catch (e) { IAP.status('Could not load your rotations.', 'bad'); } + } + function fillRotation(x) { + $('rotCardName').textContent = x.name; $('rotCardLink').textContent = rotLink(x); + $('rotPause').textContent = x.paused ? 'Resume' : 'Pause'; + $('rotCardSum').textContent = x.hits + ' hits all time, ' + x.hits7 + ' in the last 7 days, ' + x.uniques + ' unique visitors, ' + x.bots + ' bot hits filtered' + (x.fallback ? '. Fallback: ' + x.fallback : '. No fallback set: with every destination paused the link shows a not-active page.'); + const t = $('rotDests'); + t.innerHTML = 'DestinationWeightShareHits7dUniques' + (x.destinations.length ? x.destinations.map(d => { const total = x.destinations.filter(q => q.active).reduce((n, q) => n + q.weight, 0) || 1; return '' + esc(d.label || 'Destination ' + d.id) + '
    ' + esc(d.url) + '' + (d.active ? Math.round(d.weight / total * 100) + '%' : 'paused') + '' + d.hits + '' + d.hits7 + '' + d.uniques + ' '; }).join('') : 'No destinations yet. Add at least one below.'); + t.querySelectorAll('[data-rw]').forEach(i => i.addEventListener('change', async () => { try { await api('/api/my/rotations/' + x.id + '/dest/' + i.dataset.rw, { weight: i.value }); loadRotator(); } catch (e) { IAP.status(e.message, 'bad'); } })); + t.querySelectorAll('[data-rt]').forEach(b => b.addEventListener('click', async () => { const d = x.destinations.find(q => q.id === Number(b.dataset.rt)); try { await api('/api/my/rotations/' + x.id + '/dest/' + b.dataset.rt, { active: !d.active }); loadRotator(); } catch (e) { IAP.status(e.message, 'bad'); } })); + t.querySelectorAll('[data-rx]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Remove this destination? Its hit history stays in the stats.')) return; try { await api('/api/my/rotations/' + x.id + '/dest/' + b.dataset.rx, { remove: true }); loadRotator(); } catch (e) { IAP.status(e.message, 'bad'); } })); + $('rotCard').hidden = false; + fetch('/api/my/rotations/' + x.id + '/stats').then(r => r.json()).then(st => { + if (!st || st.error) return; + const lst = (arr, label) => arr.length ? '
    ' + label + '
    ' + arr.map(a => esc(a.k) + ' ' + a.n).join(' · ') + '
    ' : ''; + $('rotStats').innerHTML = '
    ' + lst(st.country, 'By country') + lst(st.source, 'By source') + lst(st.device, 'By device') + '
    ' + (st.days.length ? '

    Last days: ' + st.days.slice(-10).map(d => d.day.slice(5) + ' ' + d.n).join(' · ') + '

    ' : ''); + }).catch(() => {}); + } + function openRotation(id) { const x = ROT.find(r => r.id === id); if (!x) return; ROT_ID = id; fillRotation(x); $('rotCard').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } + if ($('rotNew')) $('rotNew').addEventListener('submit', async e => { e.preventDefault(); try { const r = await api('/api/my/rotations', { name: $('rotName').value, fallback: $('rotFallback').value }); $('rotName').value = ''; $('rotFallback').value = ''; ROT_ID = r.rotation.id; await loadRotator(); } catch (err) { IAP.status(err.message, 'bad'); } }); + if ($('rotAdd')) $('rotAdd').addEventListener('submit', async e => { e.preventDefault(); if (!ROT_ID) return; try { await api('/api/my/rotations/' + ROT_ID + '/dest', { url: $('rotUrl').value, label: $('rotLabel').value, weight: $('rotWeight').value }); $('rotUrl').value = ''; $('rotLabel').value = ''; $('rotWeight').value = 1; loadRotator(); } catch (err) { IAP.status(err.message, 'bad'); } }); + if ($('rotCopy')) $('rotCopy').addEventListener('click', async () => { try { await navigator.clipboard.writeText($('rotCardLink').textContent); $('rotCopy').textContent = 'Copied'; setTimeout(() => { $('rotCopy').textContent = 'Copy link'; }, 1500); } catch (e) { IAP.status('Copy failed; select the link by hand.', 'bad'); } }); + if ($('rotPause')) $('rotPause').addEventListener('click', async () => { const x = ROT.find(r => r.id === ROT_ID); if (!x) return; try { await api('/api/my/rotations/' + x.id, { paused: !x.paused }); loadRotator(); } catch (e) { IAP.status(e.message, 'bad'); } }); + if ($('rotDelete')) $('rotDelete').addEventListener('click', async () => { if (!ROT_ID || !await IAP.confirmBox('Delete this rotation? Its short link stops working immediately.')) return; try { await api('/api/my/rotations/' + ROT_ID, { remove: true }); ROT_ID = null; $('rotCard').hidden = true; loadRotator(); } catch (e) { IAP.status(e.message, 'bad'); } }); + if ($('rotClose')) $('rotClose').addEventListener('click', () => { ROT_ID = null; $('rotCard').hidden = true; }); + async function loadProspects() { + try { + const r = await (await fetch('/api/my/prospects')).json(); + if (r.error) return; + PP_STATUSES = r.statuses || PP_STATUSES; + const sel = $('ppStatus'); if (sel && !sel.options.length) sel.innerHTML = PP_STATUSES.map(s => '').join(''); + const list = r.prospects || []; const el = $('prospectList'); if (!el) return; + if (!list.length) { el.innerHTML = '

    Nobody on the list yet.

    '; return; } + const today = new Date(); today.setHours(0, 0, 0, 0); + el.innerHTML = list.map(p => { const due = p.nextTs && p.nextTs <= today.getTime() + 86399999; return '
    ' + esc(p.name) + (due ? ' follow up' : '') + '' + + '' + esc(p.contact || '') + (p.note ? ' · ' + esc(p.note) : '') + '' + + '' + + '' + + '
    '; }).join(''); + const save = async (id, patch) => { const p = list.find(x => x.id === Number(id)); if (!p) return; try { await api('/api/my/prospects', Object.assign({}, p, patch)); } catch (e) { IAP.status(e.message, 'bad'); } }; + el.querySelectorAll('[data-pstatus]').forEach(s => s.addEventListener('change', () => save(s.dataset.pstatus, { status: s.value }))); + el.querySelectorAll('[data-pnext]').forEach(i => i.addEventListener('change', () => save(i.dataset.pnext, { next: i.value, nextTs: i.value ? Date.parse(i.value + 'T12:00:00') : null }))); + el.querySelectorAll('[data-pdel]').forEach(b => b.addEventListener('click', async () => { try { await api('/api/my/prospects/remove', { id: b.dataset.pdel }); loadProspects(); } catch (e) { IAP.status(e.message, 'bad'); } })); + } catch (e) {} + } + if ($('prospectForm')) $('prospectForm').addEventListener('submit', async e => { + e.preventDefault(); + try { + await api('/api/my/prospects', { name: $('ppName').value, contact: $('ppContact').value, status: $('ppStatus').value, nextTs: $('ppNext').value ? Date.parse($('ppNext').value + 'T12:00:00') : null }); + $('ppName').value = ''; $('ppContact').value = ''; $('ppNext').value = ''; loadProspects(); + } catch (err) { IAP.status(err.message, 'bad'); } + }); + // ── broadcast templates ── + const BC_TEMPLATES = [ + { label: 'Welcome', subject: 'Welcome to my line: your first three moves', html: '

    Glad you are in. Three things today, in this order:

    1. Pick your username on the Profile tab (it becomes your link).
    2. Wallet tab: Connect and link wallet, then Switch on payouts. Both are free.
    3. Copy your invite link from My line and send it to one person.

    Reply here if you get stuck on any of them. That is what I am here for.

    ' }, + { label: 'Switch on payouts', subject: 'One free step so nothing passes you by', html: '

    Quick reminder: if payouts are not switched on yet, do it now on the Wallet tab. One free transaction.

    The contract locks each buyer to their sponsor at their first purchase, and payouts only route to wallets that are switched on. Ready early and you never miss one.

    ' }, + { label: 'The $5 test', subject: 'See a payout land in real time', html: '

    Want to see the whole thing work? Buy the $5 Micro package on Buy packages and watch the live ledger while you do it. You will see your credits mint and the split go out in the same transaction.

    When you are ready to count as a qualifying buyer for me, the $20 Activation package is the one.

    ' }, + { label: 'Qualified Start', subject: 'How to open level 2 today with your own positions', html: '

    You can be your own first buyers, openly. On Buy packages, the Qualified Start card lets you link a second wallet you own as a position. When it buys a $20 package, it counts as a qualifying buyer, half comes straight back to your main wallet, and the credits pool with yours.

    Two positions open level 2 the same day. The three Qualified Start videos in Training show every click.

    ' }, + { label: 'Share your link', subject: 'One conversation a day is the whole job', html: '

    Promo tools has posts, texts and emails that already carry your link. Pick one and send it to one person today.

    Do not wait for the perfect moment. Nobody who waited ever built a line.

    ' } + ]; + (function () { + const w = $('bcTemplates'); if (!w) return; + w.innerHTML = BC_TEMPLATES.map((t, i) => '').join(''); + w.querySelectorAll('[data-bct]').forEach(b => b.addEventListener('click', () => { const t = BC_TEMPLATES[Number(b.dataset.bct)]; $('bcSubject').value = t.subject; $('bcEd').innerHTML = t.html; $('bcEd').focus(); })); + })(); + // ── Qualified Start calculator ── + (function () { + const n = $('qcN'), pk = $('qcPkg'), out = $('qcOut'); if (!n || !pk || !out) return; + const CR = { 20: 2000, 50: 5500, 100: 12000, 250: 32500 }; + const calc = () => { + const k = Math.max(1, Math.min(10, Number(n.value) || 1)), usd = Number(pk.value) || 20; + const gross = k * usd, back = gross / 2, net = gross - back, credits = k * (CR[usd] || 0); + const level = k >= 5 ? 'Level 3 open (and level 2): the Nexus badge, wall position 3, full 50 / 20 / 10' : k >= 2 ? 'Level 2 open: 20% on your directs\' buyers, wall position 2' : 'Counts as one qualifying buyer. One more opens level 2.'; + out.innerHTML = '
    $' + gross + ' out across ' + k + ' position' + (k === 1 ? '' : 's') + '
    $' + back + ' back to your main wallet in the same transactions (the 50% direct-sponsor share)
    $' + net + ' net, plus a little POL for gas in each wallet
    ' + + '
    ' + credits.toLocaleString() + ' credits pooled for your own ads
    ' + level + '
    The 20% and 10% shares go to your upline if they are qualified, otherwise to the platform.
    '; + }; + n.addEventListener('input', calc); pk.addEventListener('change', calc); calc(); + })(); + + // ── 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.own ? ' yours' : '') + '' + + '' + (m.email ? esc(m.email) : (m.memberId ? '#' + m.memberId + '' : '')) + (m.sponsor ? 'sponsored by ' + esc(m.sponsor) + '' : '') + (m.own ? '' : '' + (m.bought ? '$20+ buy ✓' : 'no $20+ buy yet') + (m.buyers ? ' · ' + m.buyers + ' qualifying buyer' + (m.buyers === 1 ? '' : 's') + ' of their own' : '') + '') + '' + + '' + + (m.earnedWei && m.earnedWei !== '0' ? '+' + IAP.fmtPol(m.earnedWei) + ' POL' : '0.00 POL') + '' + + '' + new Date(m.joined).toLocaleDateString() + '' + + (m.email ? '' : '') + + (m.ref && !m.own ? '' : '') + + '
    ' + (m.ref && !m.own ? '' : '')).join('') + + '
    ').join(''); + el.querySelectorAll('[data-cemail]').forEach(b => + b.addEventListener('click', () => openConvo(b.dataset.cemail, b.dataset.cname))); + el.querySelectorAll('[data-act]').forEach(b => b.addEventListener('click', () => toggleLineActivity(b))); + } catch (e) {} + } + // who is working: per-member activity drop-down under the row (any of the three levels) + const lineActCache = {}; + async function toggleLineActivity(btn) { + const box = document.querySelector('[data-actbox="' + btn.dataset.act + '"]'); if (!box) return; + const open = box.hidden; box.hidden = !open; btn.setAttribute('aria-expanded', String(open)); btn.textContent = open ? 'Activity ▴' : 'Activity ▾'; + if (!open) return; + if (!lineActCache[btn.dataset.act]) { + box.innerHTML = 'Loading…'; + try { lineActCache[btn.dataset.act] = await (await fetch('/api/my/line/activity?ref=' + encodeURIComponent(btn.dataset.act))).json(); } catch (e) { lineActCache[btn.dataset.act] = { error: 'Could not load.' }; } + } + const a = lineActCache[btn.dataset.act]; + if (a.error) { box.innerHTML = '' + esc(a.error) + ''; return; } + const ago = t => { if (!t) return 'never'; const d = Date.now() - t; if (d < 3600e3) return Math.max(1, Math.round(d / 60e3)) + ' min ago'; if (d < 86400e3) return Math.round(d / 3600e3) + 'h ago'; return Math.round(d / 86400e3) + 'd ago'; }; + const chip = (label, val, cls) => '' + esc(label) + '' + esc(val) + ''; + const working = (a.viewsToday || 0) > 0 || (a.campaignsActive || 0) > 0 || (a.linkViews30 || 0) > 0 || (a.lastSeen && Date.now() - a.lastSeen < 2 * 86400e3); + box.innerHTML = chip('Last seen', ago(a.lastSeen), a.lastSeen && Date.now() - a.lastSeen < 2 * 86400e3 ? 'on' : (a.quietDays >= 3 ? 'warn' : '')) + + chip('Joined', new Date(a.joined).toLocaleDateString()) + + chip('Stage', (a.rung || 'Joined') + (a.next ? ' · next: ' + a.next : ''), a.stalled ? 'warn' : '') + + chip('Ads today', (a.viewsToday || 0) + '/' + (a.target || 5) + (a.claimedToday ? ' claimed' : '') + (a.streakDay ? ' · streak day ' + a.streakDay : ''), (a.viewsToday || 0) > 0 ? 'on' : '') + + chip('Campaigns', (a.campaignsActive || 0) + ' active of ' + (a.campaigns || 0) + (a.imps ? ' · ' + a.imps.toLocaleString() + ' views' : ''), (a.campaignsActive || 0) > 0 ? 'on' : '') + + chip('Link', (a.linkViews30 || 0) + ' views (30d) · ' + (a.joins || 0) + ' joined', (a.linkViews30 || 0) > 0 ? 'on' : '') + + chip('Line', (a.directs || 0) + ' direct' + (a.directs === 1 ? '' : 's') + ' · ' + (a.buyerCount || 0) + ' qualifying', (a.buyerCount || 0) > 0 ? 'on' : '') + + chip('Wallet', a.wallet ? (a.badges && a.badges.includes('payouts') ? 'linked · payouts on' : 'linked') : 'not linked', a.wallet ? '' : 'warn') + + (a.badges && a.badges.length ? chip('Badges', a.badges.map(b => ({ payouts: 'Spark', firstBuyer: 'Surge', level2: 'Circuit', level3: 'Nexus' }[b] || b)).join(' · ')) : '') + + '' + (working ? 'Working' : 'Quiet') + ''; + } + 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', async () => { + const sel = window.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null; + const u = await IAP.ask({ title: 'Insert link', text: 'Link URL (https://…)', placeholder: 'https://', ok: 'Insert' }); + if (u) { $('bcEd').focus(); if (range) { sel.removeAllRanges(); sel.addRange(range); } 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) { + for (const id of ['inboxBadge', 'inboxBadge2']) { + const b = $(id); + if (b) { b.hidden = !n; b.textContent = n; } + } + } + // Earn credits sub-tabs: Watch ads | Inbox + let earnSub = 'watch'; + function setEarnSub(which) { + earnSub = ['inbox', 'videos', 'visits'].includes(which) ? which : 'watch'; + if (earnSub !== 'videos' && vidState.token) stopVideo(); + const w = $('earn-watch'), i = $('earn-inbox'), v = $('earn-videos'), vs = $('earn-visits'); + if (w) w.hidden = earnSub !== 'watch'; + if (i) i.hidden = earnSub !== 'inbox'; + if (v) v.hidden = earnSub !== 'videos'; + if (vs) vs.hidden = earnSub !== 'visits'; + document.querySelectorAll('.subtabs [data-earn]').forEach(b => + b.classList.toggle('on', b.dataset.earn === earnSub)); + if (earnSub === 'inbox') loadInbox(); + else if (earnSub === 'videos') loadVideoStatus(); + else if (earnSub === 'visits') loadVisitStatus(); + else earnRefresh(); + } + document.querySelectorAll('.subtabs [data-earn]').forEach(b => + b.addEventListener('click', () => setEarnSub(b.dataset.earn))); + // promo toolkit: badge tiers + the AI Copy Engine (Marty, 2026-09-14) + let tkState = null; + async function loadToolkit() { + try { + const r = await (await fetch('/api/my/toolkit')).json(); if (r.error) return; tkState = r; + const esc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const names = { free: 'Free', spark: 'Spark', surge: 'Surge', circuit: 'Circuit', nexus: 'Nexus' }; + $('tkTierLine').textContent = 'you are at ' + names[r.tier]; + $('tkTiers').innerHTML = r.tiers.map(t => '
    ' + esc(t.name) + (t.reached ? ' ✓' : '') + '
    ' + (t.reached ? esc(t.blurb) : 'Unlocks when you ' + esc(t.needText)) + '
      ' + t.items.map(i => '' + esc(i.t) + '').join('') + '
    ' + (t.reached ? '' : '
    locked
    ') + '
    ').join(''); + $('tkLocked').hidden = r.unlocked; $('tkForm').hidden = !r.unlocked; + if (!r.unlocked) { $('tkLocked').textContent = 'Unlocks at Surge: your first qualifying buyer of a $20 or larger package. Then it writes posts, DMs, follow-ups, objection replies, emails and story posts in your name, with your link, inside the honesty rules. ' + (r.tier === 'free' ? 'Step one is switching on payouts.' : 'You are one buyer away.'); $('tkUsage').textContent = 'locked'; renderTkTools(r); return; } + $('tkUsage').textContent = r.freeLeft + ' free this month · then ' + r.cost + ' credits each · you have ' + r.available.toLocaleString() + ' credits'; + $('tkCostLine').textContent = r.freeLeft > 0 ? 'This one is free (' + r.freeLeft + ' left this month).' : 'This one costs ' + r.cost + ' credits from your earned pool.'; + if (!$('tkKind').options.length) { $('tkKind').innerHTML = r.kinds.map(k => '').join(''); $('tkAngle').innerHTML = r.angles.map(a => '').join(''); } + renderTkTools(r); + $('tkHistory').innerHTML = r.history.length ? '

    Recent

    ' + r.history.map(h => '
    ' + esc(h.kind) + ' · ' + new Date(h.ts).toLocaleString() + (h.charged ? ' · ' + h.charged + ' credits' : ' · free') + '
    ' + esc(h.text).replace(/\n/g, '
    ') + '
    ').join('') : ''; + } catch (e) { console.error('toolkit', e); } + } + // the rest of the ladder: Spark templates + handout, Circuit Video Maker + split tester, Nexus Leader Ops + const tkEsc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const tkRank = { free: 0, spark: 1, surge: 2, circuit: 3, nexus: 4 }; + async function tkPost(url, body) { const r = await (await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })).json(); if (r.error) { IAP.status(r.error, 'bad'); return null; } return r; } + function renderTkTools(r) { + const at = tkRank[r.tier] || 0; + // Spark + const spark = at >= 1; $('tkSparkLocked').hidden = spark; $('tkSparkBody').hidden = !spark; + if (!spark) $('tkSparkLocked').textContent = 'Unlocks at Spark: switch on payouts in the Wallet tab. Then one tap builds a banner or text campaign aimed at your link, and you get a printable handout with your QR.'; + else { $('tkHandoutLine').hidden = !r.handoutUrl; if (r.handoutUrl) $('tkHandout').href = r.handoutUrl; } + // Circuit + const circ = at >= 3; $('tkCircuitLocked').hidden = circ; $('tkCircuitBody').hidden = !circ; + if (!circ) $('tkCircuitLocked').textContent = 'Unlocks at Circuit: two qualifying buyers in your line. Then every promo video gets your own end card and QR, and the split tester shows which join angle pulls for you.'; + else { loadTkVideos(r); loadTkSplit(); } + // Nexus + const nex = at >= 4; $('tkNexusLocked').hidden = nex; $('tkNexusBody').hidden = !nex; + if (!nex) $('tkNexusLocked').textContent = 'Unlocks at Nexus: five qualifying buyers. Then you get team triage across three levels with one-click nudges, AI-drafted team broadcasts, credit grants to your people, a co-branded join page and your own partner code.'; + else { loadTkTeam(); renderTkPartner(r); } + } + let tkVidTimer = null; + async function loadTkVideos(r) { + const box = $('tkVideos'); + if (!r.videoMaker) { box.innerHTML = '

    The Video Maker is warming up on the server. Check back shortly.

    '; return; } + if (!r.username) { box.innerHTML = '

    Pick a username first (Profile); it goes on the end card.

    '; return; } + try { + const v = await (await fetch('/api/my/toolkit/videos')).json(); if (v.error) return; + box.innerHTML = v.list.map(x => '
    ' + tkEsc(x.title) + '' + (x.status === 'done' ? 'Download ' : x.status === 'queued' || x.status === 'working' ? '
    ' + tkEsc(x.stage || 'Starting') + (x.status === 'working' ? ' · ' + (x.pct || 0) + '%' : '') + '' : '') + '
    ').join(''); + clearTimeout(tkVidTimer); if (v.list.some(x => x.status === 'queued' || x.status === 'working')) tkVidTimer = setTimeout(() => loadTkVideos(r), 2500); + } catch (e) {} + } + async function loadTkSplit() { + try { + const sp = await (await fetch('/api/my/toolkit/split')).json(); if (sp.error) return; + const rows = sp.rows.filter(x => x.views || x.joins); + $('tkSplit').innerHTML = !rows.length ? '

    No link views yet. Share two different angle links this week and the winner shows up here.

    ' + : '
    ' + rows.map(x => '').join('') + '
    AngleViewsLast 30dJoinsBuyersJoin rate
    ' + tkEsc(x.angle) + (x.angle === sp.best ? ' (winner so far)' : '') + '
    ' + tkEsc(x.link) + '
    ' + x.views + '' + x.views30 + '' + x.joins + '' + x.buyers + '' + x.rate + '%

    Rate = joins per 100 views. A winner needs at least 10 views. Send more traffic to the winner, keep testing the rest.

    '; + } catch (e) {} + } + let tkTeamData = null; + async function loadTkTeam() { + try { + const t = await (await fetch('/api/my/toolkit/team')).json(); if (t.error || !t.unlocked) return; tkTeamData = t; + $('tkTeamSub').textContent = t.total + ' in three levels (' + t.byLevel.join(' / ') + ') · ' + t.recent + ' new this week · ' + t.stalled.length + ' stalled'; + const list = t.stalled.length ? t.stalled : t.members.slice(0, 12); + $('tkTeam').innerHTML = (t.stalled.length ? '

    Stalled first: quiet for a while and not yet past the next rung. One click sends them the right message from you.

    ' : '

    Nobody stalled right now. Your newest people:

    ') + + list.map(m => '
    ' + tkEsc(m.name) + ' L' + m.level + ' · ' + tkEsc(m.label) + (m.quietDays ? ' · quiet ' + m.quietDays + 'd' : '') + '
    Next: ' + tkEsc(m.next) + '
    ').join(''); + $('tkGrantTo').innerHTML = t.members.map(m => '').join(''); + } catch (e) {} + } + function renderTkPartner(r) { + const pk = r.partner; + $('tkPartner').innerHTML = pk + ? '

    Code ' + tkEsc(pk.code) + ' gives ' + pk.credits + ' welcome credits, funded from your earned pool at each redemption. Redeemed ' + pk.uses + ' times.

    Invite link with the code: ' + tkEsc(pk.link) + '

    Your partner kit page (for list owners and site owners you talk to): ' + tkEsc(pk.kit) + '

    ' + : '

    Give your recruits a welcome bonus under your own code. Each redemption moves the credits from your earned pool to theirs, so it only costs you when it works. Then hand partners the kit page, which sends their deals under you.

    '; + } + document.addEventListener('click', async ev => { + const b = ev.target.closest('[data-tpl],[data-mkvid],[data-copyvid],[data-nudge],[data-copy],#tkPcGo,#tkGrantGo,#tkDraftBc,[data-sendnudge]'); if (!b) return; + if (b.dataset.tpl) { b.disabled = true; const r = await tkPost('/api/my/toolkit/template', { kind: b.dataset.tpl, budget: Number($('tkTplBudget').value) }); b.disabled = false; if (r) { IAP.status('Campaign "' + r.name + '" created with ' + r.budget + ' credits. It is under Campaigns.', 'ok'); loadDashboard(); } return; } + if (b.dataset.mkvid) { b.disabled = true; const r = await tkPost('/api/my/toolkit/video', { slug: b.dataset.mkvid }); if (r) { IAP.status(r.status === 'done' ? 'Already made.' : 'Rendering. It appears here in about a minute.', 'ok'); loadTkVideos(tkState); } else b.disabled = false; return; } + if (b.dataset.copyvid || b.dataset.copy) { try { await navigator.clipboard.writeText(b.dataset.copyvid || b.dataset.copy); IAP.status('Copied.', 'ok'); } catch (e) { IAP.status('Copy failed; select it by hand.', 'bad'); } return; } + if (b.dataset.nudge) { const row = b.closest('.tk-team'); const old = row.querySelector('.tk-nudge'); if (old) { old.remove(); return; } const box = document.createElement('div'); box.className = 'tk-nudge'; box.innerHTML = '
    Lands in their inbox and their email.
    '; box.querySelector('textarea').value = b.dataset.say; row.appendChild(box); return; } + if (b.dataset.sendnudge) { const box = b.closest('.tk-nudge'); b.disabled = true; const r = await tkPost('/api/my/toolkit/nudge', { email: b.dataset.sendnudge, text: box.querySelector('textarea').value }); b.disabled = false; if (r) { IAP.status('Sent.', 'ok'); box.remove(); } return; } + if (b.id === 'tkGrantGo') { b.disabled = true; const r = await tkPost('/api/my/toolkit/grant', { email: $('tkGrantTo').value, credits: Number($('tkGrantN').value) }); b.disabled = false; if (r) { IAP.status(r.credits + ' credits moved to ' + r.toName + '.', 'ok'); loadToolkit(); loadDashboard(); } return; } + if (b.id === 'tkPcGo') { b.disabled = true; const r = await tkPost('/api/my/toolkit/promo', { code: b.dataset.code || ($('tkPcCode') ? $('tkPcCode').value : ''), credits: Number($('tkPcN').value) }); b.disabled = false; if (r) { IAP.status('Code ' + r.code + ' is live: ' + r.credits + ' welcome credits.', 'ok'); loadToolkit(); } return; } + if (b.id === 'tkDraftBc') { if (!tkState || !tkState.unlocked) return; $('tkKind').value = 'broadcast'; $('tkBrief').placeholder = 'What should the team focus on this week?'; $('tkEngine').scrollIntoView({ behavior: 'smooth', block: 'start' }); $('tkBrief').focus(); IAP.status('Pick a focus, hit Write it, then Send to my team.', 'ok'); return; } + }); + $('tkKind') && $('tkKind').addEventListener('change', () => { if ($('tkSendBc')) $('tkSendBc').hidden = $('tkKind').value !== 'broadcast'; }); + async function tkSendBroadcast() { + const txt = $('tkText').value || ''; const m = /^\s*Subject:\s*(.+)\n+([\s\S]+)$/.exec(txt); + const subject = m ? m[1].trim() : 'A note from your sponsor', body = (m ? m[2] : txt).trim(); + if (!(await IAP.confirmBox('Send this to everyone in your three levels? One broadcast a day.', { title: 'Team broadcast', ok: 'Send' }))) return; + const r = await tkPost('/api/my/broadcast', { subject, body: '

    ' + tkEsc(body).replace(/\n{2,}/g, '

    ').replace(/\n/g, '
    ') + '

    ', scope: 'all' }); + if (r) IAP.status('Sent to ' + (r.sent || r.count || r.recipients || 'your team') + '.', 'ok'); + } + async function tkGenerate() { + const btn = $('tkGo'); if (btn.disabled) return; btn.disabled = true; btn.textContent = 'Writing…'; + try { + const r = await (await fetch('/api/my/toolkit/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: $('tkKind').value, angle: $('tkAngle').value, brief: $('tkBrief').value }) })).json(); + if (r.error) { IAP.status(r.error, 'bad'); return; } + $('tkText').value = r.text; $('tkOut').hidden = false; $('tkSendBc').hidden = $('tkKind').value !== 'broadcast'; + IAP.status(r.charged ? r.charged + ' credits used.' : 'Written. ' + r.freeLeft + ' free left this month.', 'ok'); + loadToolkit(); loadDashboard(); + } catch (e) { IAP.status('The engine did not answer. Try again.', 'bad'); } + finally { btn.disabled = false; btn.textContent = 'Write it'; } + } + if ($('tkGo')) { $('tkGo').addEventListener('click', tkGenerate); $('tkAgain').addEventListener('click', tkGenerate); $('tkCopy').addEventListener('click', async () => { try { await navigator.clipboard.writeText($('tkText').value); IAP.status('Copied.', 'ok'); } catch (e) { $('tkText').select(); } }); $('tkSendBc').addEventListener('click', tkSendBroadcast); } + // promo tools: pill menu switches between posts / swipes / banners / wall / videos + function setPromoSub(name) { + const ids = ['toolkit', 'posts', 'text', 'swipe', 'banners', 'wall', 'objections', 'videos']; + if (name === 'toolkit') loadToolkit(); + if (!ids.includes(name)) name = 'posts'; + ids.forEach(id => { const el = $('promo-' + id); if (el) el.hidden = id !== name; }); + document.querySelectorAll('.promo-pills [data-promo]').forEach(b => { + b.classList.toggle('on', b.dataset.promo === name); + b.setAttribute('aria-selected', b.dataset.promo === name ? 'true' : 'false'); + }); + try { localStorage.setItem('iap.promoSub', name); } catch (e) {} + } + document.querySelectorAll('.promo-pills [data-promo]').forEach(b => + b.addEventListener('click', () => setPromoSub(b.dataset.promo))); + try { setPromoSub(localStorage.getItem('iap.promoSub') || 'posts'); } catch (e) { setPromoSub('posts'); } + async function loadInbox() { + try { + const r = await (await fetch('/api/my/inbox')).json(); + if (r.error) return; + $('ibRewardNote').textContent = '+' + r.readCredits + ' credits per real read (up to ' + + r.readCap + ' rewarded reads a day)'; + const el = $('ibList'); + $('inboxReadCard').hidden = true; + $('inboxListCard').hidden = false; + setInboxBadge(r.unread); + if (!r.items.length) { + el.innerHTML = '

    No solo ads yet. When a member sends one, it lands here — and reading it pays.

    '; + return; + } + el.innerHTML = ''; + for (const i of r.items) { + const d = document.createElement('div'); + d.className = 'ib-row' + (i.read ? '' : ' unread'); + d.innerHTML = '' + + (i.rewarded ? 'claimed' : i.read ? '' : 'new') + + '' + new Date(i.delivered).toLocaleDateString() + ''; + d.querySelector('.sub').textContent = i.subject || '(no subject)'; + d.querySelector('.from').textContent = 'from ' + (i.fromName || 'a member'); + d.addEventListener('click', () => openInboxItem(i.id)); + el.appendChild(d); + } + } catch (e) {} + } + async function openInboxItem(id) { + try { + const r = await (await fetch('/api/my/inbox/' + id)).json(); + if (r.error) { IAP.status(r.error, 'bad'); return; } + $('inboxListCard').hidden = true; + $('inboxReadCard').hidden = false; + $('ibSubject').textContent = r.subject || '(no subject)'; + $('ibMeta').textContent = 'from ' + (r.fromName || 'a member') + ' · ' + new Date(r.delivered).toLocaleString(); + $('ibBody').innerHTML = r.body || ''; // whitelist-sanitized on the server at submit + const mv = $('ibMedia'); + mv.hidden = !r.mediaUrl; + mv.innerHTML = !r.mediaUrl ? '' + : r.mediaType === 'video' + ? '' + : 'attachment'; + // the read reward needs BOTH the dwell AND an actual click-through to the + // advertiser — the visit is what makes the ad worth the sender's credits + const visit = $('ibVisit'); + visit.href = r.url; + visit.textContent = r.ctaLabel || 'Learn more'; + visit.target = '_blank'; + const btn = $('ibClaimBtn'); + clearInterval(ibTimer); + if (r.rewarded) { + btn.hidden = true; + visit.classList.remove('cta-need'); + $('ibHint').textContent = 'Read reward already claimed for this one.'; + return; + } + let dwellDone = false; + let visited = !!r.visited; + btn.hidden = false; + btn.disabled = true; + visit.classList.toggle('cta-need', !visited); + let left = r.dwell; + const refresh = () => { + if (!dwellDone) { btn.textContent = 'Read it — claim in ' + left + 's'; return; } + if (!visited) { btn.textContent = 'Claim +' + r.reward + ' — visit the ad first'; btn.disabled = true; return; } + btn.textContent = 'Claim +' + r.reward + ' credits'; btn.disabled = false; + }; + $('ibHint').textContent = 'Read the message, click through to the advertiser, then claim your credits.'; + refresh(); + // countdown pauses off-tab; the server separately enforces the dwell on its own clock + ibTimer = setInterval(() => { + if (document.visibilityState !== 'visible' || !document.hasFocus()) return; + left -= 1; + if (left > 0) { refresh(); return; } + clearInterval(ibTimer); + dwellDone = true; + refresh(); + }, 1000); + // clicking the CTA records the visit (and counts the advertiser's click) + visit.onclick = async () => { + visited = true; + visit.classList.remove('cta-need'); + try { await fetch('/api/my/inbox/' + id + '/visit', { method: 'POST' }); } catch (e2) {} + refresh(); + }; + btn.onclick = async () => { + try { + const c = await api('/api/my/inbox/' + id + '/claim'); + IAP.status('+' + c.credited + ' credits for reading. They spend like any earned credits.', 'ok'); + btn.hidden = true; + $('ibHint').textContent = 'Claimed. Head back for the next one.'; + loadDashboard(); + } catch (e2) { IAP.status(e2.message, 'bad'); } + }; + } catch (e) {} + } + $('ibBack').addEventListener('click', ev => { ev.preventDefault(); clearInterval(ibTimer); loadInbox(); }); + + // ── promo tools: content + rendering live in promo.js (IAPPromo.fill) ── + function fillPromo(link, me) { + if (window.IAPPromo) IAPPromo.fill(link, me || {}); + // banner kit + const bwrap = $('promoBanners'); + if (bwrap && !bwrap.dataset.filled) { + bwrap.dataset.filled = '1'; + // one collapsed accordion per size / use, so the kit stays scannable as it grows + const GROUPS = [ + { title: '1200×630 · social posts, link previews, Daily News covers', items: [ + { file: 'iap-hero-1200x630.png', size: '1200×630 · hero · advertise and earn' }, + { file: 'iap-advertise-earn-1200x630.jpg', size: '1200×630 · advertise and earn instantly, locked in code' }, + { file: 'iap-team-build-tiers-1200x630.jpg', size: '1200×630 · team build and instant payments · 50 / 20 / 10 tiers' }, + { file: 'iap-team-build-tiers-v2-1200x630.jpg', size: '1200×630 · team build and instant payments · variant 2' }, + { file: 'iap-instant-payments-tiers-1200x630.jpg', size: '1200×630 · instant payments, direct commissions · tiers' }, + { file: 'iap-instant-payments-tiers-v2-1200x630.jpg', size: '1200×630 · instant payments · variant 2' }, + { file: 'iap-multistream-info-1200x630.jpg', size: '1200×630 · multi-stream revenue · "INFO or message me" CTA' }, + { file: 'iap-multistream-info-v2-1200x630.jpg', size: '1200×630 · multi-stream revenue · variant 2' }, + { file: 'iap-success-path-info-1200x630.jpg', size: '1200×630 · success path, training and coaching · "INFO or message me" CTA' }, + { file: 'iap-success-path-link-1200x630.jpg', size: '1200×630 · success path · "link in the description" (feed posts, Daily News)' }, + { file: 'iap-success-path-coaching-1200x630.jpg', size: '1200×630 · success path · quality network traffic' } ] }, + { title: '1080×1080 and 1080×1920 · Instagram, Facebook, stories, reels', items: [ + { file: 'iap-1080x1080.png', size: '1080×1080 · square' }, + { file: 'iap-1080x1920.png', size: '1080×1920 · story / reel' } ] }, + { title: '1280×720 · Telegram and group posts', items: [ + { file: 'iap-1280x720.png', size: '1280×720 · group post' } ] }, + { title: 'Leaderboards · 728×90, 468×60, 320×50', items: [ + { file: 'iap-728x90.png', size: '728×90 · leaderboard' }, + { file: 'iap-advertise-earn-728x90.png', size: '728×90 · advertise and earn instantly · Join free' }, + { file: 'iap-ledger-728x90.png', size: '728×90 · advertising that pays you on-chain · See the ledger' }, + { file: 'iap-468x60.png', size: '468×60 · banner' }, + { file: 'iap-ledger-468x60.png', size: '468×60 · advertising that pays you on-chain · See the ledger' }, + { file: 'iap-320x50.svg', size: '320×50 · mobile leaderboard' } ] }, + { title: 'Rectangles and buttons · 336×280, 300×250, 125×125', items: [ + { file: 'iap-336x280.png', size: '336×280 · large rectangle' }, + { file: 'iap-advertise-earn-336x280.png', size: '336×280 · advertise and earn instantly' }, + { file: 'iap-advertise-earn-v2-336x280.png', size: '336×280 · advertise and earn · variant 2' }, + { file: 'iap-300x250.png', size: '300×250 · rectangle' }, + { file: 'iap-advertise-earn-300x250.png', size: '300×250 · advertise and earn instantly' }, + { file: 'iap-team-build-link-300x250.jpg', size: '300×250 · team build and instant payments · "link in the description"' }, + { file: 'iap-125x125.png', size: '125×125 · square button' } ] }, + { title: 'Skyscrapers · 160×600, 120×600', items: [ + { file: 'iap-160x600.png', size: '160×600 · wide skyscraper' }, + { file: 'iap-120x600.png', size: '120×600 · skyscraper' } ] } + ]; + bwrap.innerHTML = ''; + for (const g of GROUPS) { + const det = document.createElement('details'); + det.className = 'pb-acc'; + det.innerHTML = '' + esc(g.title) + '' + g.items.length + (g.items.length === 1 ? ' banner' : ' banners') + '
    '; + const grid = det.querySelector('.pb-grid'); + for (const b of g.items) { + const url = location.origin + '/banners/' + b.file; + const d = document.createElement('div'); + d.className = 'pb-item'; + d.innerHTML = 'LinkSpin banner ' + b.size + '' + + '
    ' + b.size + '
    '; + const dl = document.createElement('a'); + dl.className = 'btn small'; dl.textContent = 'Download'; dl.href = '/banners/' + b.file; dl.setAttribute('download', b.file); + d.querySelector('.pb-row').appendChild(dl); + const btn = document.createElement('button'); + btn.className = 'btn small sec'; + btn.textContent = 'Copy image URL'; + btn.addEventListener('click', async () => { + try { await navigator.clipboard.writeText(url); IAP.status('Banner URL copied.', 'ok'); } + catch (e) { IAP.status('Copy failed.', 'bad'); } + }); + d.querySelector('.pb-row').appendChild(btn); + grid.appendChild(d); + } + bwrap.appendChild(det); + } + } + } + + // ── profile ── (busy2 defers the busy lookup past its TDZ) + $('pfSaveBtn').addEventListener('click', busy2($('pfSaveBtn'), async () => { + const r = await api('/api/my/profile', { username: $('pfUsername').value }); + IAP.status('You are @' + r.account.username + ' now.', 'ok'); + await render(); + })); + + // ── in-dashboard package buying ── + const PKG = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' }; + // Card on-ramp: buy POL with a card via MoonPay, delivered to the buyer's own + // wallet. Signed + wallet-prefilled once MoonPay keys are set; generic page + // otherwise. The site never touches funds — MoonPay is merchant of record. + async function openMoonpay(pol) { + try { + let addr = ''; + try { const me = await (await fetch('/api/me')).json(); addr = me.address || ''; } catch (e) {} + const q = '/api/moonpay-url?pol=' + encodeURIComponent(pol || '') + (addr ? '&address=' + encodeURIComponent(addr) : ''); + const r = await (await fetch(q)).json(); + if (r && r.url) { + window.open(r.url, '_blank', 'noopener'); + IAP.status(r.signed + ? 'MoonPay opened in a new tab with your wallet address pre-filled. Choose POL on Polygon, finish the purchase, then come back and buy your package.' + : 'MoonPay opened in a new tab. Choose POL on the Polygon network and paste your own wallet address as the destination, then come back.', 'ok'); + } + } catch (e) { IAP.status('Could not open MoonPay: ' + ((e && e.message) || e), 'bad'); } + } + // ── linked positions (Qualified Start) ── + const short = a => a ? a.slice(0, 6) + '…' + a.slice(-4) : ''; + let noPayoutAddrs = new Set(); // positions the site refuses to buy from (see siteConfig.noPayoutIds) + async function loadPositions(me) { + try { + const r = await (await fetch('/api/my/positions')).json(); + if (r.error) return; + const list = r.positions || []; + const rows = list.map((p, i) => '
    Position ' + (i + 2) + ' ' + short(p.address) + '' + + '' + (p.memberId ? '#' + p.memberId : 'not on-chain yet') + '' + + '' + (p.noPayout ? 'linkage only: no purchases from this position (payouts would reach a retired wallet)' : p.counted ? 'counts as a qualifying buyer' : p.memberId ? 'registered, buy $20+ to count' : 'buy a $20+ package to register it') + '' + + '' + (p.credits || 0).toLocaleString() + ' credits' + (p.balanceWei != null ? ' · ' + (Number(BigInt(p.balanceWei) / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' : '') + '' + + (!p.memberId ? '' : '') + + '
    ').join(''); + const mainRow = r.main && r.main.address ? '
    Position 1 · main ' + short(r.main.address) + '' + + '' + (r.main.memberId ? '#' + r.main.memberId : 'payouts not on yet') + '' + + '' + (r.main.buyerCount || 0) + ' qualifying buyer(s)' + + '' + (r.main.credits || 0).toLocaleString() + ' credits' + (r.main.balanceWei != null ? ' · ' + (Number(BigInt(r.main.balanceWei) / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' : '') + '
    ' : ''; + const html = mainRow + rows + (list.length ? '

    Pooled credits: ' + (r.totalCredits || 0).toLocaleString() + '' + (r.credited ? ' plus ' + r.credited.toLocaleString() + ' credited to your account (spends from any position)' : '') + '. A campaign budget spends from one position at a time.

    ' : ''); + if ($('qsList')) $('qsList').innerHTML = html; + // Wallet tab: live POL balance of the linked wallet, and which package it covers + const wb = $('walletBal'); + if (wb && r.main && r.main.address && r.main.balanceWei != null) { + const polN = Number(BigInt(r.main.balanceWei) / 10n ** 14n) / 10000; + const usd = r.polUsd ? polN * r.polUsd : 0; + const pkgs = [5, 20, 50, 100, 250]; + const covers = r.polUsd ? pkgs.filter(p => usd >= p * 1.06 + 0.05) : []; + wb.hidden = false; + wb.innerHTML = 'Wallet balance: ' + polN.toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' + (usd ? ' (about $' + usd.toLocaleString(undefined, { maximumFractionDigits: 0 }) + ')' : '') + + (r.polUsd ? '
    ' + (covers.length ? 'Enough for the $' + covers[covers.length - 1] + ' package with gas to spare.' : 'Not enough for the $5 package yet. Buy POL with a card on the Buy packages tab, or send POL to this wallet.') + (covers.length && covers.length < pkgs.length ? ' Top up for the $' + pkgs[covers.length] + ' package.' : '') + '' : ''); + } + if ($('posList')) $('posList').innerHTML = html; + if ($('posCard')) $('posCard').hidden = !list.length; + // "Buy from" picker: main + every position + const sel = $('buyFrom'); + if (sel) { + const keep = sel.value; + sel.innerHTML = '' + + list.map((p, i) => '').join(''); + noPayoutAddrs = new Set(list.filter(p => p.noPayout).map(p => p.address.toLowerCase())); if (r.main && r.main.noPayout && r.main.address) noPayoutAddrs.add(r.main.address.toLowerCase()); + if (keep && [...sel.options].some(o => o.value === keep)) sel.value = keep; + $('buyFromWrap').hidden = !list.length; + } + document.querySelectorAll('[data-unlink]').forEach(b => b.addEventListener('click', async () => { + if (!(await IAP.confirmBox('Unlink ' + short(b.dataset.unlink) + ' from your account?', { title: 'Unlink position', ok: 'Unlink' }))) return; + try { await api('/api/my/positions/remove', { address: b.dataset.unlink }); IAP.status('Position unlinked.', 'ok'); loadPositions(); } + catch (e) { IAP.status(e.message, 'bad'); } + })); + } catch (e) {} + } + if ($('qsAddBtn')) $('qsAddBtn').addEventListener('click', busy2($('qsAddBtn'), async () => { + const me = await (await fetch('/api/me')).json(); + if (!me.address) throw new Error('Link your main wallet first (Wallet tab), then add positions under it.'); + if (!me.memberId) throw new Error('Switch on payouts for your main wallet first (Wallet tab). Positions register under your member number.'); + if (!(await IAP.confirmBox('Your wallet will ask which account to connect. Tick ONLY the new account (not ' + short(me.address) + '), then sign once.\n\nIf you have not created the extra account yet: MetaMask, account menu, Add account. Trust or SafePal: switch wallet.\n\nReady?', { title: 'Add a position', ok: 'Ready' }))) return; + IAP.status('Pick the new account in your wallet, then sign once…'); + const r = await IAPWallet.signIn({ asPosition: true, pick: true }); + $('qsHint').textContent = 'Added ' + short(r.address) + '. Now choose it under "Buy from" and buy a $20 or larger package.'; + IAP.status('Position added: ' + short(r.address) + '. Pick it under "Buy from" above and buy a $20+ package to count it.', 'ok'); + await loadPositions(); + const sel = $('buyFrom'); if (sel) sel.value = r.address.toLowerCase(); + try { $('buyFrom').scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) {} + })); + async function loadBuyTiles() { + try { + const { products } = await (await fetch('/api/catalog')).json(); + const wrap = $('boTiles'); + wrap.innerHTML = ''; + for (const p of products) { + const bonus = p.creditAmount - p.priceCents; + const div = document.createElement('div'); + div.className = 'tile' + (p.priceCents === 5000 ? ' hot' : ''); + div.innerHTML = '
    ' + (PKG[p.id] || 'Package ' + p.id) + '
    ' + + '
    $' + Math.round(p.priceCents / 100) + '
    ' + + '
    ' + p.creditAmount.toLocaleString() + ' credits
    ' + + '
    ' + (bonus > 0 ? '+' + bonus.toLocaleString() + ' bonus credits' : ' ') + '
    ' + + '
    ' + (p.costWei ? IAP.fmtPol(p.costWei) + ' POL right now' : 'paused') + '
    ' + + ''; + wrap.appendChild(div); + } + wrap.querySelectorAll('button[data-id]').forEach(b => b.addEventListener('click', async () => { + try { + b.disabled = true; + // the buyer's credits are read by the member id of their LINKED wallet. + // If they only connected a wallet (e.g. for the faucet) but never linked + // it, link it first (one signature) so the purchase's credits show up. + // retry these through transient failures — mobile drops in-flight + // fetches when the page returns from the wallet app-switch + const jretry = async url => { let err; for (let i = 0; i < 4; i++) { try { return await (await fetch(url)).json(); } catch (e) { err = e; await new Promise(s => setTimeout(s, 500 * (i + 1))); } } throw err; }; + const meNow = await jretry('/api/me'); + // which of the member's wallets is buying: the main wallet (default) or a + // linked position (Qualified Start). A position registers under the main + // member id on its first buy, so its sponsor is always this member. + const fromSel = $('buyFrom'); + const fromPos = (fromSel && !$('buyFromWrap').hidden && fromSel.value && fromSel.value !== 'main') ? fromSel.value.toLowerCase() : null; + if (fromPos && !meNow.memberId) { IAP.status('Switch on payouts for your main wallet first (Wallet tab), so this position can register under you.', 'bad'); return; } + if (noPayoutAddrs.has(fromPos || String(meNow.address || '').toLowerCase())) { IAP.status('This position is kept for linkage only. A purchase from it would pay a retired wallet. Buy from your main wallet or another position.', 'bad'); return; } + if (!fromPos && !meNow.address) { + IAP.status('Link your wallet first — one quick signature…'); + await IAPWallet.signIn(); + } + const wantAddr = fromPos || (meNow.address ? meNow.address.toLowerCase() : null); + if (wantAddr) { // never buy from a wallet other than the one selected: a stray wallet would register a brand-new member + await IAPWallet.connect(); + let cur = String(await IAPWallet.activeAddress() || '').toLowerCase(); + if (cur !== wantAddr) { + IAP.status('Pick ' + wantAddr.slice(0, 6) + '…' + wantAddr.slice(-4) + ' in your wallet\'s account picker…'); + cur = String(await IAPWallet.pickAccount() || '').toLowerCase(); + } + if (cur !== wantAddr) throw new Error('Your wallet connected as ' + cur.slice(0, 6) + '…' + cur.slice(-4) + ' but you chose ' + wantAddr.slice(0, 6) + '…' + wantAddr.slice(-4) + '. Switch accounts in your wallet app and try again.'); + } + const spNow = fromPos ? { sponsorId: meNow.memberId } : await jretry('/api/sponsor'); + if (!fromPos && !meNow.memberId && spNow.sponsorBlocked) { IAP.status(sponsorHoldText(spNow), 'bad'); return; } // first activation must not hand a sponsored member to the company + // pre-flight: stop early if the POL is not there. Trust Wallet also hard-blocks any + // transaction that spends most of the balance ("drain your wallet"), so Trust users + // get a heads-up first; other wallets go straight to the confirmation. + try { + const need = BigInt(b.dataset.cost) + BigInt(b.dataset.cost) / 50n; // same 2% pad as buy() + const bal = await IAPWallet.balance(wantAddr || IAPWallet.address() || meNow.address); + if (bal < need) { + IAP.status('That wallet holds ' + IAP.fmtPol(bal.toString()) + ' POL, but this package needs about ' + IAP.fmtPol(need.toString()) + ' POL plus a little for gas. Top it up and try again.', 'bad'); + return; + } + const pct = Number(need * 100n / bal); + const isTrust = /trust/i.test(IAPWallet.walletName() || ''); + if (isTrust && pct > 55 && !(await IAP.confirmBox('Heads up for Trust Wallet users: this purchase uses about ' + pct + '% of the POL in your wallet, and Trust Wallet refuses transactions that spend most of the balance (it shows a "drain your wallet" warning with only Stop and go back).' + '\n\n' + 'Options: pick a smaller package first, add some POL, or connect a different wallet (MetaMask, Phantom, SafePal). Extra POL always stays yours.' + '\n\n' + 'Try it anyway?', { title: 'Trust Wallet check', ok: 'Buy anyway', cancel: 'Pause' }))) { + IAP.status('Purchase paused. Pick a smaller package, add POL, or connect another wallet, then try again.', 'ok'); + return; + } + } catch (e) { /* balance read failed: let the wallet decide */ } + IAP.status('Confirm the purchase in your wallet…'); + const r = await IAPWallet.buy(Number(b.dataset.id), spNow.sponsorId || 0, b.dataset.cost); + if (r.status !== '0x1' && r.receipt && r.receipt.status !== '0x1') throw new Error('Transaction reverted.'); + IAP.status(fromPos ? 'Purchase settled on-chain. That position now counts toward your qualification, and its credits pool with yours.' : 'Purchase settled on-chain. Credits are in your account.', 'ok'); + await render(); + loadBuyTiles(); + } catch (e) { IAP.status('Purchase failed: ' + ((e && e.message) || e), 'bad'); } + finally { b.disabled = false; } + })); + // brand new to crypto? buy POL with a card, sent straight to the wallet + const builder = products.find(p => p.priceCents === 5000) || products[products.length - 1]; + const needPol = (builder && builder.costWei) ? Math.max(30, Math.ceil(Number(builder.costWei) / 1e18) + 3) : 30; + const cta = document.createElement('div'); + cta.style.cssText = 'grid-column:1/-1;margin-top:10px;text-align:center'; + cta.innerHTML = '

    New to crypto? Buy POL with a debit or credit card, Apple Pay, or Google Pay. It lands straight in your own wallet, and this site never touches your money.

    ' + + ' Wallet and MoonPay guide'; + wrap.appendChild(cta); + const mb = $('moonpayBtn'); if (mb) mb.addEventListener('click', () => openMoonpay(needPol)); + } catch (e) {} + } + loadBuyTiles(); + + // ── earn-by-viewing: daily ad set with dwell, then claim ── + const earnState = { types: ['banner', 'text'], i: 0, timer: null }; + async function earnRefresh() { + try { + const st = await (await fetch('/api/my/earn')).json(); + if (st.error) return null; + $('earnProgress').textContent = 'today: ' + st.views + ' / ' + st.target + ' ads viewed'; + $('earnBalance').textContent = (st.earnedAvailable != null ? st.earnedAvailable : st.earned) + ' earned credits available' + (st.reserved ? ' · ' + st.reserved + ' in live campaigns' : ''); + const done = st.views >= st.target; + $('earnClaimBtn').hidden = !(done && !st.claimed); + if (st.claimed) { + $('earnHint').textContent = 'Claimed for today' + (st.streakDay > 1 ? ' (streak day ' + st.streakDay + ')' : '') + '. Tomorrow\'s claim pays ' + st.nextClaim + ' credits if you come back.'; + // after the set (Marty, 2026-09-12): keep going with verified visits, and turn the credits into a campaign + const box = $('earnAdBox'); + if (box) box.innerHTML = '
    Ads done for todayToday\'s set is viewed and claimed. Fresh ads tomorrow.' + + '
    ' + + (st.visitsLeft ? '' : '') + + ((st.earnedAvailable != null ? st.earnedAvailable : st.earned) > 0 + ? '' + : '') + '
    '; + if (box) box.querySelectorAll('[data-earnnext]').forEach(b => b.addEventListener('click', () => { if (b.dataset.earnnext === 'campaigns') setPane('campaigns'); else setEarnSub('visits'); })); + if ($('earnStartBtn')) $('earnStartBtn').hidden = true; + } else { + if ($('earnStartBtn')) $('earnStartBtn').hidden = done; // set done: the claim button takes over + $('earnHint').textContent = done + ? 'Set complete. Claim your ' + st.claimCredits + ' credits' + (st.streakDay > 1 ? ' (streak day ' + st.streakDay + ')' : '') + '.' + : (st.views ? (st.target - st.views) + ' more to go, then claim ' + st.claimCredits + ' credits' + (st.streakDay > 1 ? ' (streak day ' + st.streakDay + ')' : '') + '.' : 'View ' + st.target + ' ads to unlock the daily claim of ' + st.claimCredits + ' credits' + (st.streakDay > 1 ? ' (streak day ' + st.streakDay + ')' : '') + '.'); + } + return st; + } catch (e) { return null; } + } + async function earnShowAd() { + // each view happens full screen in its own tab: /view/ frames the + // advertiser's site, runs the countdown, then a human check credits it + const type = earnState.types[earnState.i++ % earnState.types.length]; + let r = null; + try { r = await (await fetch('/api/my/earnview?type=' + type)).json(); } catch (e) {} + const box = $('earnAdBox'); + if (!r || !r.ad || !r.viewUrl) { + await earnRefresh(); + box.innerHTML = '' + (r && r.status && r.status.views >= r.status.target + ? 'Set complete for today.' + : 'No member ads are live in rotation right now. Views resume the moment a campaign is running.') + ''; + return; + } + openAdOverlay(r.viewUrl); + box.innerHTML = 'Watch the countdown and pass the quick check. Your view credits itself and this page updates right away.'; + $('earnStartBtn').textContent = 'View next ad'; + } + // In-page ad overlay: no new tab (mobile and in-app wallet browsers handle tabs + // badly), no window.close needed. The /view page runs the dwell + check inside, + // messages back when it credits or when the user is done. + function openAdOverlay(url) { + closeAdOverlay(); + const back = document.createElement('div'); + back.id = 'adOverlay'; + back.style.cssText = 'position:fixed;inset:0;z-index:100000;background:#050b09;display:flex;flex-direction:column'; + const x = document.createElement('button'); + x.type = 'button'; x.setAttribute('aria-label', 'Close ad'); + x.textContent = '✕'; + x.style.cssText = 'position:absolute;top:10px;right:12px;z-index:2;width:40px;height:40px;border-radius:50%;border:1px solid rgba(255,255,255,.25);background:rgba(6,10,16,.85);color:#fff;font-size:20px;font-weight:700;cursor:pointer'; + x.addEventListener('click', closeAdOverlay); + const ifr = document.createElement('iframe'); + ifr.src = url; + ifr.style.cssText = 'flex:1;width:100%;border:0;background:#050b09'; + back.appendChild(ifr); back.appendChild(x); + document.body.appendChild(back); + document.body.style.overflow = 'hidden'; + } + function closeAdOverlay() { + const m = $('adOverlay'); if (m) m.remove(); + document.body.style.overflow = ''; + earnRefresh(); loadDashboard(); + } + window.addEventListener('message', e => { + if (e.origin !== location.origin || !e.data) return; + if (e.data.t === 'iap-view-close') closeAdOverlay(); + else if (e.data.t === 'iap-view-done') { earnRefresh(); loadDashboard(); } + }); + $('earnStartBtn').addEventListener('click', () => earnShowAd()); + // the viewer tab pings localStorage when a view credits; refresh instantly + window.addEventListener('storage', e => { + if (e.key === 'iap-view-done') { earnRefresh(); loadDashboard(); } + }); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && $('pane-earn') && !$('pane-earn').hidden) earnRefresh(); + }); + $('earnClaimBtn').addEventListener('click', async () => { + try { + const r = await api('/api/my/claim'); + IAP.status('+' + r.credited + ' credits earned' + (r.streakDay > 1 ? ', streak day ' + r.streakDay : '') + '. Tomorrow\'s claim pays ' + r.nextClaim + '. Spend them in Campaigns.', 'ok'); + await earnRefresh(); + loadDashboard(); + } catch (e) { IAP.status(e.message, 'bad'); } + }); + + const busy = (btn, fn) => async () => { + try { btn.disabled = true; await fn(); } + catch (e) { IAP.status((e && e.message) || String(e), 'bad'); } + finally { btn.disabled = false; } + }; + + // passwordless (feature-flagged on config.emailAuth): code replaces passwords + (async () => { + const cfg = await IAP.getConfig(); + if (!cfg.emailAuth) return; + $('passCards').hidden = true; + $('magicCard').hidden = false; + const codeOpts = () => ({ honeypot: $('mcWebsite'), host: $('mcCheck') }); + const start = busy($('mcSendBtn'), async () => { + const r = await IAP.requestCode($('mcEmail').value, codeOpts()); + $('mcCodeRow').hidden = false; + $('mcVerifyBtn').hidden = false; + $('mcSendBtn').hidden = true; + $('mcResend').hidden = false; + if (r.devCode) { $('mcCode').value = r.devCode; IAP.status('Dev mode: code filled in for you.', 'ok'); } + else IAP.status('Code sent. Check your inbox (and spam, the first time).', 'ok'); + $('mcCode').focus(); + }); + $('mcSendBtn').addEventListener('click', start); + $('mcResend').addEventListener('click', busy($('mcResend'), async () => { + const r = await IAP.requestCode($('mcEmail').value, codeOpts()); + if (r.devCode) $('mcCode').value = r.devCode; + IAP.status('Fresh code sent.', 'ok'); + })); + $('mcVerifyBtn').addEventListener('click', busy($('mcVerifyBtn'), async () => { + const r = await api('/api/auth/email/verify', { email: $('mcEmail').value, code: $('mcCode').value, newsletter: !!($('nlOptin') && $('nlOptin').checked) }); + IAP.status('You are in.', 'ok'); + if (r.created && !(r.account && r.account.username)) await showOnboard(); // pick a username first + if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad + await render(); + })); + })(); + + // new-member onboarding: choose a username (required), optionally a bio. + // required=true: no skip, no backdrop dismiss, prefilled suggestion, resolves only after a save. + function showOnboard(required) { + return new Promise(resolve => { + const m = $('onboardModal'); if (!m) return resolve(); + m.hidden = false; $('obErr').hidden = true; + $('obSkip').hidden = !!required; + if (required && !$('obUsername').value) { + fetch('/api/my/username-suggest').then(r => r.json()).then(r => { if (r.suggest && !$('obUsername').value) { $('obUsername').value = r.suggest; $('obUsername').select(); } }).catch(() => {}); + } + setTimeout(() => $('obUsername').focus(), 50); + const done = () => { m.hidden = true; resolve(); }; + $('obSkip').onclick = required ? null : done; + $('obUsername').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); $('obSave').click(); } }; + $('obSave').onclick = async () => { + const u = $('obUsername').value.trim(); + if (required && !u) { $('obErr').hidden = false; $('obErr').textContent = 'Pick a username to continue.'; return; } + try { + if (u) await api('/api/my/profile', { username: u }); + const bio = $('obBio').value.trim(); + if (bio) await api('/api/my/profile-details', { bio }); + done(); + } catch (e) { $('obErr').hidden = false; $('obErr').textContent = e.message || 'Could not save that. Try a different username.'; } + }; + }); + } + + $('signupBtn').addEventListener('click', busy($('signupBtn'), async () => { + const r = await api('/api/signup', { email: $('suEmail').value, password: $('suPass').value, newsletter: !!($('nlOptin') && $('nlOptin').checked) }); + IAP.status('Welcome aboard. You are in.', 'ok'); + if (!(r.account && r.account.username)) await showOnboard(); + await render(); + })); + $('loginBtn').addEventListener('click', busy($('loginBtn'), async () => { + await api('/api/login', { email: $('liEmail').value, password: $('liPass').value }); + IAP.status('Logged in.', 'ok'); + if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad + await render(); + })); + $('linkBtn').addEventListener('click', busy($('linkBtn'), async () => { + IAP.status('Check your wallet for the free link signature…'); + await IAPWallet.signIn(); // server binds the wallet to the signed-in email account + IAP.status('Wallet linked. Earnings pay there from now on.', 'ok'); + await render(); + })); + if ($('faucetBtn')) $('faucetBtn').addEventListener('click', busy($('faucetBtn'), async () => { + IAP.status('Connect your wallet first…'); + const addr = await IAPWallet.connect(); + let copied = false; + try { await navigator.clipboard.writeText(addr); copied = true; } catch (e) {} + window.open('https://faucet.polygon.technology/', '_blank', 'noopener'); + $('faucetInfo').innerHTML = 'Your address ' + addr.slice(0, 8) + '…' + addr.slice(-6) + '' + + (copied ? ' is copied' : '') + '. On the faucet, choose Polygon Amoy, paste your address, and request POL. Then come back and buy.'; + IAP.status('Faucet opened in a new tab. Choose Polygon Amoy, paste your address, and request test POL.', 'ok'); + })); + if ($('wcDisconnect')) $('wcDisconnect').addEventListener('click', busy($('wcDisconnect'), async () => { + // fire-and-forget: don't gate the reload on disconnect resolving (it can + // stall). The reload drops all in-memory wallet state and the picker returns. + IAPWallet.disconnect().catch(() => {}); + $('wcDisconnectInfo').textContent = 'Disconnecting — reloading so you can pick a different wallet…'; + IAP.status('Disconnecting — reloading…', 'ok'); + setTimeout(() => location.reload(), 900); + })); + // a named sponsor that cannot be paid right now: hold the transaction and say why (never credit the company by default) + function sponsorHoldText(sp) { + const who = sp.sponsorName || 'your sponsor'; + if (sp.sponsorBlocked === 'claim' && sp.claim) return (sp.claim.nobody ? 'Nobody above you on LinkSpin has a wallet yet, so there is no one for the contract to pay. ' : who + ' has not linked a wallet on LinkSpin yet. They have until ' + new Date(sp.claim.deadline).toLocaleString() + ' to do it, and we have told them. ') + 'You can use everything else meanwhile; your purchase waits so it pays the right person.'; + if (sp.sponsorBlocked === 'notActivated') return who + ' has not switched on payouts yet, so this purchase would credit the company instead of them. Ask them to switch on payouts (Wallet tab, one free transaction), then try again.'; + if (sp.sponsorBlocked === 'rpc') return 'Could not confirm your sponsor on the chain just now. Try again in a minute; nothing was charged.'; + return 'Your sponsor link could not be matched to a member. Message support before buying so ' + who + ' gets credit.'; + } + $('activateBtn').addEventListener('click', busy($('activateBtn'), async () => { + const me = await (await fetch('/api/me')).json(); + if (me.sponsorBlocked) { IAP.status(sponsorHoldText(me), 'bad'); return; } + IAP.status('Confirm the free activation in your wallet…'); + const r = await IAPWallet.activate(me.sponsorId || 0); + if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.'); + IAP.status('Payouts are on. Your invite link is live.', 'ok'); + await render(); + })); + $('copyInvite').addEventListener('click', async () => { + try { await navigator.clipboard.writeText($('inviteLine').textContent); IAP.status('Link copied.', 'ok'); } + catch (e) { IAP.status('Copy failed. Select and copy the link text.', 'bad'); } + }); + $('logoutLink').addEventListener('click', async e => { + e.preventDefault(); + await fetch('/api/auth/logout', { method: 'POST' }); + IAP.status('Logged out.', 'ok'); + await render(); + }); + + // ── welcome tour (viral banner gauntlet): a new member meets their 3-level + // upline's sites, 10 focus-paused seconds each, then unlocks welcome credits. + // Same three levels the contract pays — the tour IS the org chart. + async function showGauntlet() { + try { + const g = await (await fetch('/api/my/gauntlet')).json(); + if (!g.pending || !g.slides || !g.slides.length) return false; + const gate = $('gauntGate'); + gate.hidden = false; + for (let i = 0; i < g.slides.length; i++) { + const s = g.slides[i]; + $('ggWho').textContent = 'Position ' + (i + 1) + ': ' + s.name + (i === 0 ? ' — the person who invited you' : ''); + $('ggProgress').textContent = 'Meeting your line: ' + (i + 1) + ' of ' + g.slides.length; + $('ggFrame').hidden = false; + $('ggFrame').src = s.targetUrl; + let left = g.dwell || 10; + $('ggTimer').textContent = left + 's'; + await new Promise(done => { + const t = setInterval(() => { + if (document.visibilityState !== 'visible' || !document.hasFocus()) return; + left -= 1; + $('ggTimer').textContent = Math.max(0, left) + 's'; + if (left <= 0) { clearInterval(t); done(); } + }, 1000); + }); + } + $('ggFrame').src = 'about:blank'; + $('ggFrame').hidden = true; // avoid a white about:blank panel once the tour is done + $('ggTimer').textContent = '✓'; + $('ggWho').textContent = 'That is your line. When you grow, they earn — and yours starts the day you share.'; + $('ggClaim').hidden = false; + await new Promise(done => { + $('ggClaim').onclick = async () => { + try { + const r = await api('/api/my/gauntlet/complete', { token: g.token }); + IAP.status('+' + r.credited + ' welcome credits unlocked. They spend on real campaigns.', 'ok'); + } catch (e) { IAP.status(e.message, 'bad'); } + done(); + }; + }); + gate.hidden = true; + $('ggClaim').hidden = true; + return true; + } catch (e) { return false; } + } + + // ── line banner (profile): the member's slot on welcome tours + their wall ── + function fillLineBanner(a) { + if (!a) return; + if (a.lineTargetUrl) $('lbTarget').value = a.lineTargetUrl; + if (a.lineBannerUrl) { + $('lbBanner').value = a.lineBannerUrl; + $('lbPreview').hidden = false; + $('lbPreview').innerHTML = 'line banner'; + } + $('lbCurrent').textContent = a.lineTargetUrl + ? 'Live: your next three levels meet ' + a.lineTargetUrl + ' on their welcome tour.' + : 'Not set yet. Until you set one, your tour slot is skipped.'; + } + async function loadLineBanner() { + try { + const a = await (await fetch('/api/me')).json(); + fillLineBanner(a); + fillProfileDetails(a); + // buyerCount + wallUnlocked live on the dashboard payload (chain read), not on /api/me + let d = {}; try { d = await (await fetch('/api/my/dashboard')).json(); } catch (e) {} + fillWallOffers(Object.assign({}, a, { buyerCount: d.buyerCount || 0, wallUnlocked: d.wallUnlocked || 1 })); + } catch (e) {} + } + // ── wall positions 2 & 3: the member's own offers, unlocked by qualifying buyers ── + function fillWallOffers(a) { + if (!a || !$('wallOffersCard')) return; + const unlocked = a.wallUnlocked || 1, bc = a.buyerCount || 0; + const offers = Array.isArray(a.wallOffers) ? a.wallOffers : []; + const NEED = [2, 5]; + for (let i = 0; i < 2; i++) { + const o = offers[i] || {}; + const open = unlocked >= i + 2; + $('woTitle' + i).value = o.title || ''; $('woTarget' + i).value = o.targetUrl || ''; $('woBanner' + i).value = o.bannerUrl || ''; + $('woPrev' + i).hidden = !o.bannerUrl; $('woPrev' + i).innerHTML = o.bannerUrl ? '' : ''; + $('woLock' + i).textContent = open ? 'yours' : 'unlocks at ' + NEED[i] + ' qualifying buyers (' + bc + '/' + NEED[i] + ')'; + $('woSlot' + i).classList.toggle('locked', !open); + } + $('woStatus').textContent = unlocked >= 3 ? 'Fully qualified: all three wall positions are yours.' + : unlocked === 2 ? 'Position 2 is yours. ' + (5 - bc) + ' more qualifying buyer' + (5 - bc === 1 ? '' : 's') + ' and position 3 is too.' + : (2 - bc) + ' more qualifying buyer' + (2 - bc === 1 ? '' : 's') + ' ($20 or more) opens position 2. You can set your links now; they go live the moment a slot unlocks.'; + } + document.querySelectorAll('.wo-upload').forEach(b => b.addEventListener('click', () => { const f = document.querySelector('.wo-file[data-slot="' + b.dataset.slot + '"]'); if (f) f.click(); })); + document.querySelectorAll('.wo-file').forEach(inp => inp.addEventListener('change', async () => { + const i = inp.dataset.slot, f = inp.files[0]; if (!f) return; + $('woInfo' + i).textContent = 'Uploading…'; + try { + const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json(); + if (r.error) { $('woInfo' + i).textContent = r.error; } + else { $('woBanner' + i).value = r.url; $('woInfo' + i).textContent = 'Uploaded'; $('woPrev' + i).hidden = false; $('woPrev' + i).innerHTML = ''; } + } catch (e) { $('woInfo' + i).textContent = 'Upload failed. Try again.'; } + inp.value = ''; + })); + if ($('woSaveBtn')) $('woSaveBtn').addEventListener('click', busy2($('woSaveBtn'), async () => { + const offers = [0, 1].map(i => ({ title: $('woTitle' + i).value, targetUrl: $('woTarget' + i).value, bannerUrl: $('woBanner' + i).value })); + const r = await api('/api/my/wall-offers', { offers }); + IAP.status('Wall positions saved.', 'ok'); + await loadLineBanner(); + })); + const SOCIALS = ['facebook', 'twitter', 'youtube', 'instagram', 'tiktok', 'telegram', 'linkedin', 'website', 'video']; // video = intro video on the wall, not a social link + function fillProfileDetails(a) { + if (!a) return; + if (a.bio) $('pfBio').value = a.bio; + if (a.avatarUrl) { const p = $('pfAvatarPrev'); p.src = a.avatarUrl; p.hidden = false; } + let soc = {}; try { soc = a.socials ? JSON.parse(a.socials) : {}; } catch (e) {} + for (const p of SOCIALS) if ($('soc-' + p)) $('soc-' + p).value = soc[p] || ''; + if (a.username) { + const link = location.origin + '/wall/' + a.username; + $('pfBioLink').textContent = link; + $('pfViewBio').hidden = false; + $('pfViewBio').href = '/wall/' + a.username; + } + } + let pfAvatar; // pending avatar url + $('pfAvatarBtn').addEventListener('click', () => $('pfAvatarFile').click()); + $('pfAvatarFile').addEventListener('change', async () => { + const f = $('pfAvatarFile').files[0]; + if (!f) return; + $('pfAvatarInfo').textContent = 'Uploading…'; + try { + const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json(); + if (r.error) { $('pfAvatarInfo').textContent = r.error; $('pfAvatarFile').value = ''; return; } + pfAvatar = r.url; + $('pfAvatarInfo').textContent = 'Uploaded — save to apply.'; + const p = $('pfAvatarPrev'); p.src = r.url; p.hidden = false; + } catch (e) { $('pfAvatarInfo').textContent = 'Upload failed.'; } + $('pfAvatarFile').value = ''; + }); + $('pfDetailsSave').addEventListener('click', busy2($('pfDetailsSave'), async () => { + const body = { bio: $('pfBio').value, socials: {} }; + for (const p of SOCIALS) body.socials[p] = ($('soc-' + p) && $('soc-' + p).value.trim()) || ''; + if (pfAvatar) body.avatarUrl = pfAvatar; + const r = await api('/api/my/profile-details', body); + IAP.status('Profile saved.', 'ok'); + if (r.account) fillProfileDetails(r.account); + })); + $('lbUploadBtn').addEventListener('click', () => $('lbFile').click()); + $('lbFile').addEventListener('change', async () => { + const f = $('lbFile').files[0]; + if (!f) return; + $('lbUpInfo').textContent = 'Uploading…'; + try { + const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json(); + if (r.error) { $('lbUpInfo').textContent = r.error; $('lbFile').value = ''; return; } + $('lbBanner').value = r.url; + $('lbUpInfo').textContent = 'Uploaded.'; + $('lbPreview').hidden = false; + $('lbPreview').innerHTML = 'line banner'; + } catch (e) { $('lbUpInfo').textContent = 'Upload failed. Try again.'; } + $('lbFile').value = ''; + }); + $('lbSaveBtn').addEventListener('click', busy2($('lbSaveBtn'), async () => { + const r = await api('/api/my/linebanner', { bannerUrl: $('lbBanner').value, targetUrl: $('lbTarget').value }); + IAP.status('Line banner saved. Your next three levels will meet it.', 'ok'); + if (r.account) fillLineBanner(r.account); + })); + + // ── login ad interstitial (ClickBaitPays pattern): after a successful + // sign-in the sponsor card appears; "Open Ad" opens the CTA link in a NEW + // tab (a real, counted click) while the timer counts down on THIS page — + // deliberately not paused, the member is expected to be in the ad tab. + // At zero, "Go to dashboard" appears. + async function showLoginAd() { + try { + const { ad } = await (await fetch('/api/ads/slot?type=login')).json(); + if (!ad || !ad.targetUrl) return; + const gate = $('loginGate'); + const openBtn = $('lgOpen'); + const goBtn = $('lgContinue'); + const timer = $('lgTimer'); + $('lgCreative').innerHTML = ad.imageUrl + ? 'sponsor ad' + : '' + (ad.title ? String(ad.title).replace(/[&<>]/g, '') : 'Visit today\'s sponsor') + ''; + $('lgStatus').textContent = 'Login ad sponsor — click "Open Ad" to begin.'; + timer.hidden = true; + goBtn.hidden = true; + openBtn.disabled = false; + gate.hidden = false; + await new Promise(done => { + openBtn.onclick = () => { + window.open(ad.targetUrl, '_blank'); // the click relay counts it + openBtn.disabled = true; + $('lgStatus').textContent = 'Ad open in a new tab. View it and come back — the timer runs here.'; + let left = ad.dwell || 10; + timer.hidden = false; + timer.textContent = 'Time remaining: ' + left + 's'; + const t = setInterval(() => { + left -= 1; + if (left > 0) { timer.textContent = 'Time remaining: ' + left + 's'; return; } + clearInterval(t); + timer.textContent = 'Time is up'; + timer.classList.add('done'); + $('lgStatus').textContent = 'Thanks for the look. Your dashboard is ready.'; + goBtn.hidden = false; + }, 1000); + goBtn.onclick = () => done(); + }; + }); + gate.hidden = true; + $('lgTimer').classList.remove('done'); + } 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, CHAT_SPONSOR = null, CHAT_LAST_UNREAD = 0; + function chatSync(d) { + CHAT_ME = d.email || CHAT_ME; + CHAT_SPONSOR = (d.sponsor && d.sponsor.email) ? d.sponsor : null; + const link = $('chatMenuBtn'); if (link && link.closest('.bo-links')) link.closest('.bo-links').hidden = !d.email; + setChatBadge(d.chatUnread || 0); + CHAT_LAST_UNREAD = d.chatUnread || 0; + const tog = $('chatAvailToggle'); if (tog) tog.checked = d.chatAvailable !== false; + // Overview quick action + My line card: message-your-sponsor entry points + const qs = $('qaMsgSponsor'); + if (qs) { + if (CHAT_SPONSOR) { qs.hidden = false; qs.dataset.email = CHAT_SPONSOR.email; qs.dataset.name = CHAT_SPONSOR.name || 'your sponsor'; } + else qs.hidden = true; + } + const card = $('sponsorMsgCard'); + if (card) { + card.hidden = !CHAT_SPONSOR; + if (CHAT_SPONSOR && $('sponsorMsgName')) $('sponsorMsgName').textContent = CHAT_SPONSOR.name || 'your sponsor'; + } + } + function setChatBadge(n) { const b = $('chatNavBadge'); 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); } + // the composer grows with the message up to ~45% of the screen, and keeps any height the member dragged it to (Bradley, 2026-09-13) + function autoGrow(el) { + const cap = Math.max(160, Math.floor(window.innerHeight * 0.45)); + const dragged = Number(el.dataset.dragged || 0); + el.style.height = 'auto'; + el.style.height = Math.min(cap, Math.max(dragged, 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 || []; + // always offer "message your sponsor" up top when they have one and no thread yet + const hasSponsorThread = CHAT_SPONSOR && list.some(t => t.email === CHAT_SPONSOR.email); + const sponsorRow = (CHAT_SPONSOR && !hasSponsorThread) + ? '
    ' + + '' + + '
    ' + esc(CHAT_SPONSOR.name) + '
    ' + + '
    Your sponsor · tap to message
    ' + : ''; + if (!list.length && !sponsorRow) { $('chatThreads').innerHTML = '
    No conversations yet. You can message anyone in your line from “My line”.
    '; return; } + $('chatThreads').innerHTML = sponsorRow + 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('chatMenuBtn', 'click', e => { if (e && e.preventDefault) e.preventDefault(); 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('mouseup', () => { const h = inp.getBoundingClientRect().height; inp.dataset.dragged = h > 60 ? String(Math.round(h)) : ''; }); + 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('lineMsgSponsor', 'click', () => { if (CHAT_SPONSOR) openConvo(CHAT_SPONSOR.email, CHAT_SPONSOR.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; } + }); + // presence heartbeat + new-message notifier: pop + toast when unread rises + setInterval(async () => { + try { + const r = await (await fetch('/api/my/ping')).json(); + const n = r.chatUnread || 0; + if (n > CHAT_LAST_UNREAD) { playSound('pop'); IAP.status('💬 New message from your team.', 'ok'); } + CHAT_LAST_UNREAD = n; setChatBadge(n); + } catch (e) {} + }, 20000); + })(); + + // 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'); + IAP.adSlot('banner', 'adSlotOverview'); + IAP.adSlot('banner', 'adSlotSide', { width: 125, height: 125 }); // square button ad in the sidebar + + render(); +})(); + +// ── partner promo codes: typed on the Overview (Marty, 2026-09-12) ── +(function () { + const btn = document.getElementById('promoApply'), inp = document.getElementById('promoCode'), msg = document.getElementById('promoMsg'); + if (!btn || !inp) return; + const say = (t, ok) => { msg.hidden = false; msg.textContent = t; msg.style.color = ok ? 'var(--mint)' : '#ff8a8a'; }; + const go = async () => { + const code = inp.value.trim(); if (!code) { say('Enter a promo code.', false); return; } + btn.disabled = true; + try { + const r = await (await fetch('/api/my/promo/redeem', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) })).json(); + if (r.error) { say(r.error, false); return; } + say('Added ' + Number(r.credits).toLocaleString() + ' credits' + (r.partner ? ' from ' + r.partner : '') + '. They are in your balance now.', true); + inp.value = ''; if (typeof loadDashboard === 'function') loadDashboard(); + } catch (e) { say('Could not apply that code. Try again.', false); } + finally { btn.disabled = false; } + }; + btn.addEventListener('click', go); inp.addEventListener('keydown', e => { if (e.key === 'Enter') go(); }); +})(); diff --git a/public/assets/partners.js b/public/assets/partners.js new file mode 100644 index 0000000..63519da --- /dev/null +++ b/public/assets/partners.js @@ -0,0 +1,21 @@ +// Site-owner partner page: ?name=First greets the owner, ?promo=CODE rides along into the +// company placement link and the example builder link. Display only; placement and credits +// come from the join route and the promo code. +(function () { + const q = new URLSearchParams(location.search); + const name = String(q.get('name') || '').replace(/[^A-Za-zÀ-ɏ' -]/g, '').trim().slice(0, 30); + const promo = String(q.get('promo') || '').toUpperCase().replace(/[^A-Z0-9_-]/g, '').slice(0, 24); + const ref = String(q.get('ref') || '').toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 20); // a member's own partner kit (Nexus): the deal lands under them + const hello = document.getElementById('pkHello'); + if (hello && name) { hello.textContent = name + ', this page is for you. Your spot directly under the company at the top is reserved; activate with the $20 package to claim it.'; hello.style.display = 'block'; } + const cta = document.getElementById('pkCta'); + if (cta) { + const p = new URLSearchParams(); if (name) p.set('name', name); if (promo) p.set('promo', promo); + cta.href = (ref ? '/join/' + ref : '/join/company') + (p.toString() ? '?' + p.toString() : ''); + if (name) cta.textContent = 'Claim my spot, ' + name; + } + const ex = document.getElementById('pkExample'); + if (ex && promo) ex.textContent = 'https://linkspin-test.saasy.top/join/yourname?promo=' + promo; + const sub = document.getElementById('pkCtaSub'); + if (sub && promo) sub.textContent = 'Free account by email. No password, no wallet today. The link below places you directly under the company and applies your code ' + promo + ' as well.'; +})(); diff --git a/public/assets/plays.js b/public/assets/plays.js new file mode 100644 index 0000000..4810721 --- /dev/null +++ b/public/assets/plays.js @@ -0,0 +1,14 @@ +// Team-building plays page: members only, and the scripts carry the member's own link. +(async function () { + try { await IAP.renderNav('training'); } catch (e) {} + let me = null; + try { me = await (await fetch('/api/me')).json(); } catch (e) {} + const signedIn = !!(me && me.signedIn && me.email); + document.getElementById('gate').style.display = signedIn ? 'none' : 'block'; + document.getElementById('body').style.display = signedIn ? 'block' : 'none'; + if (!signedIn) return; + try { IAP.adSlot('banner', 'adSlotPlays'); } catch (e) {} + const pb = document.getElementById('printBtn'); if (pb) pb.addEventListener('click', () => window.print()); + const tok = me.username || me.refCode || me.memberId; + if (tok) document.querySelectorAll('[data-link]').forEach(el => { el.textContent = location.origin + '/join/' + tok; }); +})(); diff --git a/public/assets/promo.js b/public/assets/promo.js new file mode 100644 index 0000000..b1c3a9a --- /dev/null +++ b/public/assets/promo.js @@ -0,0 +1,217 @@ +// Promo tools content + rendering for the member area (Promo tools pane). +// Everything is personalized from the member's join link. Angle links carry +// ?v= so they keep working when the matched squeeze pages ship. +// Copy rules: no income promises, package prices in dollars are fine, never a +// POL figure, no em dashes. +window.IAPPromo = (function () { + const $ = id => document.getElementById(id); + const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const status = (m, c) => (window.IAP && IAP.status) ? IAP.status(m, c) : console.log(m); + const angleLink = (link, angle) => angle ? link + '?v=' + angle : link; + + // ── social posts ── + const POSTS = [ + { net: 'X', label: 'X · instant payout', text: 'No withdraw button. A smart contract on Polygon splits every ad package the second it sells: 50% to the sponsor, 20% to level 2, 10% to level 3. Lands in your own wallet. Watch the ledger move: {{LINK:instant}}' }, + { net: 'X', label: 'X · advertiser', text: 'Marketers: you were buying traffic anyway. Here the ad spend in your line pays you, in the same transaction, on a public ledger. Packages from $5. Join free: {{LINK:adspend}}' }, + { net: 'X', label: 'X · free join', text: 'Join free. View a few ads, earn credits, run your first campaign for zero dollars. Seven ad formats, real dwell-timed attention, every payout public on Polygon. {{LINK:free}}' }, + { net: 'Facebook', label: 'Facebook · story post', text: 'I got tired of "your commission is pending." So I joined a platform where there is no pending. A smart contract on the Polygon blockchain splits every ad package the moment it sells: half to the sponsor, then levels two and three, then the platform. It lands in your own wallet in seconds. No approval queue, no withdrawal button, no 15th of the month.\n\nYou are buying real advertising: banners, text ads, login ads, solo ads to member inboxes, video ads, featured links and verified visits. Members earn credits for their attention, so the ads actually get seen.\n\nJoin free, look around, and check the public ledger before you spend a dollar: {{LINK:instant}}\n\nNo income promises. It is advertising, not investing, and crypto carries risk.' }, + { net: 'Facebook', label: 'Facebook · skeptic angle', text: 'Every online program I ever joined asked me to trust a back office. This one does not. Every payout is a public transaction on Polygon you can look up yourself, with the wallet addresses and amounts right there. The split is written in a verified smart contract nobody can quietly edit.\n\nFree to join with just an email. The wallet comes out only if you buy a package or switch on payouts. Packages run $5 to $250, and every one of them is ad delivery you can watch running.\n\nTake the tour: {{LINK:ledger}}' }, + { net: 'LinkedIn', label: 'LinkedIn · professional', text: 'An experiment in transparent affiliate payouts: LinkSpin sells advertising packages ($5 to $250) whose sponsor commissions are settled by a verified smart contract on Polygon in the same transaction as the purchase. 50 / 20 / 10 across three levels, 20 percent to the platform, every payment public on the chain.\n\nWhat I find interesting is not the commission. It is that "pending payout" stops being a concept. If you buy traffic for a living and want to see how same-transaction settlement works in practice, the tour is free: {{LINK:adspend}}' }, + { net: 'Telegram', label: 'Telegram or WhatsApp group', text: 'Quick one for the group. Ad platform on Polygon where every package splits to sponsor wallets the second it sells. No withdrawals, no pending. Free to join by email, packages from $5, seven ad formats, and you earn credits for viewing. Tour here: {{LINK:instant}}' }, + { net: 'Telegram', label: 'Telegram or WhatsApp group · free angle', text: 'If you want to test an ad network without spending anything: join free, view a handful of ads, earn credits, run your first campaign on the house. Payouts are on the public Polygon ledger. {{LINK:free}}' } + ]; + + // ── text a friend (SMS-sized) ── + const TEXTS = [ + { angle: '', title: '"Thought of you"', text: 'Hey, found an ad platform where the commission lands in your wallet the second someone buys. No pending, no withdraw button. Free to look: {{LINK}}' }, + { angle: 'instant', title: '"Before the page reloads"', text: 'Random thought. What if your commission showed up before the thank-you page finished loading? That is literally how this works: {{LINK:instant}}' }, + { angle: 'adspend', title: '"You buy ads anyway"', text: 'You already buy traffic. This one pays your line every time someone in it buys ads, same transaction, on Polygon. Two-minute look: {{LINK:adspend}}' }, + { angle: 'free', title: '"Costs nothing to try"', text: 'Try this without spending a dollar: join free, view a few ads, earn credits, run your first campaign. {{LINK:free}}' }, + { angle: 'ledger', title: '"No back office"', text: 'Remember waiting on payouts that never came? This one has no back office. Every payment is public on the blockchain. Check it yourself: {{LINK:ledger}}' } + ]; + + // ── email swipes ── + const SWIPES = [ + { tier: 'Short', subject: 'Paid the second it sells', body: 'Quick one.\n\nI joined an ad platform where every package splits to sponsor wallets in the same transaction as the sale. No pending payouts, no withdraw button. It is on the Polygon blockchain, and every payment is public.\n\nJoin free, look at the ledger, decide later: {{LINK:instant}}\n\nNo income promises. Advertising, not investing.' }, + { tier: 'Standard', subject: 'Your wallet gets paid instantly', body: 'You know the usual drill. Someone buys on your link, you wait for a payout. Maybe days. Maybe an approval hold. Maybe a "your account is under review."\n\nLinkSpin does not work like that.\n\nA smart contract on the Polygon blockchain handles every purchase the second it happens. 50% to the sponsor. 20% to the next level. 10% to the one after that. 20% to the platform. Each split lands directly in your own wallet. No withdrawal button. No "request payout." No approval queue.\n\nThe money just shows up.\n\nYou can watch every transaction on the public ledger. Real time. Anyone can verify it.\n\nFree to join. Packages from $5 to $250. No income promises. It is advertising, not investing.\n\n{{LINK}}' }, + { tier: 'Long', subject: 'The ad network with no pending payouts', body: 'Let me tell you what you are actually looking at, because "crypto ad platform" can mean anything.\n\nLinkSpin sells advertising. Five packages, $5 to $250. A package mints ad credits, and one credit is one cent of delivery across seven formats: display banners, text ads, full-screen login ads, solo ads delivered into member inboxes, video ads, featured links, and verified visits. Members earn credits for their attention, with a timer that pauses when they look away, so your ad is seen by a person, not a script.\n\nNow the part that made me join.\n\nWhen anyone in your line buys a package, a verified smart contract on Polygon splits the payment in that same transaction: 50 percent to their direct sponsor, 20 percent to level two, 10 percent to level three, 20 percent to the platform. It lands in real wallets in seconds. There is no balance to withdraw because nothing is ever held.\n\nEvery one of those payments is public. Open the ledger, click a transaction, read it on Polygonscan.\n\nJoining is free and only needs an email. Your wallet comes out when you buy a package or switch on payouts. If you never spend a dollar, you can still view ads, earn credits, and run a small campaign on those.\n\nHave a look: {{LINK:instant}}\n\nOne honest line: nobody is promising you an income. Results depend on your effort, and crypto carries risk of loss.' }, + { tier: 'Follow-up', subject: 'Did you see the ledger?', body: 'Following up on the ad platform I sent over.\n\nIf you only look at one thing, look at the live ledger. Every purchase and every payout, with real wallet addresses, in the order they happened. That is the whole pitch: nothing to take on faith.\n\n{{LINK:ledger}}\n\nIf it is not for you, no worries at all.' } + ]; + + // ── objection bank (truth + say this) ── + const OBJECTIONS = [ + { q: 'Which crypto do I get paid in?', truth: 'One coin, one network: POL, the native coin of Polygon. Packages are dollar-priced and settled in POL at the live Chainlink rate; every payout is sent as POL to the recipient\'s own Polygon wallet in the same transaction. No tokens, no other chains, no stablecoins.', say: 'You get paid in POL, which is Polygon\'s own coin, straight into your wallet the moment a package in your line sells. Any Polygon wallet works, MetaMask, SafePal, Phantom, and if you have never held crypto you can buy POL with a card inside the member area. {{LINK}}' }, + { q: 'Is this a pyramid scheme?', truth: 'You are buying advertising that actually runs: a credit is one cent of delivery across seven live formats, and members earn credits for dwell-timed attention. Sponsor payments are referral commissions written as constants in a verified contract, paid only when a real package sells. Nobody has to recruit to use the ads, and joining costs nothing.', say: 'Fair question. Here is the test I use: is there a real product that people would buy with no referral involved? Here the product is ad delivery, seven formats, one cent per credit, and you can watch your campaign serving. The sponsor split is a referral commission on those sales, fixed in a public contract. Join free and run the ads with zero referrals if you like. {{LINK:free}}' }, + { q: 'I do not have a crypto wallet. I am not technical.', truth: 'Joining needs only an email and a 6-digit code. The wallet comes out only when someone buys a package or switches on payouts, and the site walks them through it. A card on-ramp (MoonPay) delivers POL straight to their own wallet. Email first, wallet second.', say: 'You do not need one to join. It is email only, no password even. The wallet shows up later, only if you decide to buy a package or want payouts, and the site walks you through it step by step. Start here and look around first: {{LINK}}' }, + { q: 'What am I actually buying?', truth: 'Ad credits minted on-chain the moment a package is bought: $5 = 500, $20 = 2,000, $50 = 5,500, $100 = 12,000, $250 = 32,500. Credits spend on banners, text ads, login ads, solo ads to member inboxes, video ads, featured links and verified visits, with live stats per campaign. Only the buyer\'s campaigns can spend them.', say: 'Advertising. A package mints credits, one credit is one cent of delivery, and you spend them on seven ad formats from your own dashboard with live stats. The $5 package is 500 credits, the $250 package is 32,500. You can see the formats before you spend anything: {{LINK:adspend}}' }, + { q: 'Who holds my money?', truth: 'Nobody. A purchase is one Polygon transaction that splits to the sponsor line and the platform wallets immediately. The site never holds balances, and the contract source is verified on Polygonscan and Sourcify. Overpayment refunds itself in the same transaction.', say: 'That is the part I like most: nobody holds it. The purchase is a single blockchain transaction that pays the sponsor line and the platform in the same moment. There is no balance sitting anywhere waiting for a withdrawal. The contract code is public and verified, and every payment is on the ledger: {{LINK:ledger}}' }, + { q: 'What if POL drops?', truth: 'Packages are priced in dollars and settled in POL at the live Chainlink rate at the moment of purchase. Payouts arrive as POL in the recipient\'s own wallet immediately, so what they do with it is their call. This is advertising, not an investment, and POL is volatile like any crypto asset.', say: 'Packages are dollar priced, so $20 is $20 worth of POL at that moment. Payouts land in your wallet right away, and it is your money from that second. Crypto does move, so treat it as advertising you bought, not an investment. {{LINK}}' }, + { q: 'Why would anyone buy ads here?', truth: 'The members are marketers who already buy traffic. Viewers earn credits only after a server-timed dwell, so impressions are real people. Solo ads reach member inboxes with a guaranteed delivery count, verified visits are one unique member per visit, and banner and text ads also syndicate to a partner ad network.', say: 'Because the people on it are marketers, and they get paid to actually look. Every view is dwell-timed on the server, solo ads land in real inboxes with a guaranteed count, and verified visits are one real person each. You can watch your own campaign serve: {{LINK:adspend}}' }, + { q: 'I do not know anyone to refer.', truth: 'Referrals are optional. Members can view ads, earn credits and advertise with no line at all. When they do refer, every direct buyer pays them 50 percent from the very first package, and their line banner is shown to their next three levels during welcome tours.', say: 'You do not have to refer anyone. Join free, earn credits by viewing, run ads. If one person ever joins through you and buys a $5 package, half of it hits your wallet in that transaction. That is the whole referral side, and it is optional. {{LINK:free}}' }, + { q: 'How much can I make?', truth: 'No income is guaranteed or implied. Sponsor commissions are 50 / 20 / 10 across three levels on packages that actually sell; level 2 needs 2 qualifying buyers ($20 or more) and level 3 needs 5. Results depend on the member\'s effort, and crypto carries risk of loss. Never quote a figure.', say: 'Nobody can tell you that, and I would not trust anyone who did. What I can tell you is exactly how it is split: 50 percent to the direct sponsor, 20 and 10 to the next two levels, paid the moment a package sells, all public. What that adds up to depends entirely on what you build. {{LINK:instant}}' } + ]; + + function fillLink(text, link) { + return text.replace(/\{\{LINK(?::([a-z]+))?\}\}/g, (m, a) => angleLink(link, a)); + } + function copyBtn(text, label) { + const b = document.createElement('button'); + b.className = 'btn small sec'; b.type = 'button'; b.textContent = label || 'Copy'; + b.addEventListener('click', async () => { + try { await navigator.clipboard.writeText(text); status('Copied. Paste it anywhere.', 'ok'); } + catch (e) { status('Copy failed. Select the text instead.', 'bad'); } + }); + return b; + } + function block(text, head, extraBtns) { + const div = document.createElement('div'); + div.className = 'promo-block'; + if (head) { const h = document.createElement('div'); h.className = 'pb-head'; h.innerHTML = head; div.appendChild(h); } + const pre = document.createElement('div'); pre.className = 'pb-text'; pre.textContent = text; div.appendChild(pre); + const row = document.createElement('div'); row.className = 'pb-actions'; + row.appendChild(copyBtn(text)); + (extraBtns || []).forEach(b => row.appendChild(b)); + div.appendChild(row); + return div; + } + function linkBtn(href, label, cls) { + const a = document.createElement('a'); + a.className = 'btn small ' + (cls || 'sec'); a.href = href; a.textContent = label; + if (!/^sms:/.test(href)) { a.target = '_blank'; a.rel = 'noopener'; } + return a; + } + + // hook videos: one per angle; the matched link continues the same hook on the join page + const VID_BASE = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/'; + const VIDEOS = [ + { angle: 'instant', title: 'Paid before the page reloads', hook: 'What if your commission landed before the thank-you page finished loading?', caption: 'What if your commission landed before the thank-you page finished loading? On LinkSpin a smart contract splits every ad package the second it sells, straight to wallets, on a public ledger. Free to join by email: {{LINK:instant}}' }, + { angle: 'adspend', title: 'You were buying traffic anyway', hook: 'Every ad dollar you have ever spent went one direction. Out.', caption: 'Every ad dollar you have ever spent went one direction. Out. Here the ad spend in your line pays you back in the same transaction. Seven formats, packages from $5, free to join: {{LINK:adspend}}' }, + { angle: 'free', title: 'Watch first, spend never', hook: 'You can run your first ad campaign here for exactly zero dollars.', caption: 'Run your first ad campaign for exactly zero dollars. Join free by email, earn credits by viewing ads, launch a real campaign, and watch real payouts land on a public ledger before you spend a cent: {{LINK:free}}' }, + { angle: 'ledger', title: 'No back office. No payday.', hook: 'Your last affiliate program paid you on the fifteenth. If it paid you.', caption: 'Your last affiliate program paid you on the fifteenth. If it paid you. Here the payroll is the blockchain: every payout is a public transaction, nothing is held, nobody can change the split. Open the ledger, then join free: {{LINK:ledger}}' }, + { angle: 'two', title: 'Two buyers open level two', hook: 'Two buyers. Then five. That is the whole ladder.', caption: 'Two buyers. Then five. That is the whole ladder. Level 1 pays from your first buyer, two qualifying buyers open level 2, five open level 3, all in the same transaction, straight to your wallet. Free to join: {{LINK:two}}' } + ]; + // the six front doors: the plain invite link plus one page per hook angle + const ANGLES = [ + { angle: '', name: 'General', hook: 'The whole picture', use: 'What LinkSpin is, the payment split, join free. Use it when you have not said anything specific yet.' }, + { angle: 'instant', name: 'Instant', hook: 'Paid before the page reloads', use: 'For anyone burned by pending commissions. The page opens on the on-chain payout that lands in seconds.' }, + { angle: 'adspend', name: 'Ad spend', hook: 'You buy ads anyway', use: 'For marketers who already pay for traffic. Frames it as advertising that also pays your line when they buy.' }, + { angle: 'free', name: 'Free', hook: 'Costs nothing to try', use: 'For skeptics and beginners. Join free, earn credits by viewing, run a first campaign without spending.' }, + { angle: 'ledger', name: 'Ledger', hook: 'No back office, no payday', use: 'For people who have waited on a payout. Every payment is public on Polygonscan and nothing is ever held.' }, + { angle: 'two', name: 'Two', hook: 'Two buyers open level two', use: 'For team builders. The 2-then-5 qualification ladder and how a line stacks under you.' } + ]; + function fill(link, me) { + const token = (me && (me.username || me.refCode || me.memberId)) || ''; + // invite strip + if ($('promoLink')) $('promoLink').textContent = link; + if ($('promoLinkCopy')) $('promoLinkCopy').onclick = async () => { + try { await navigator.clipboard.writeText(link); status('Link copied.', 'ok'); } catch (e) { status('Copy failed.', 'bad'); } + }; + // angle links: one row per front door, copy + share + const al = $('promoAngles'); + if (al && al.dataset.filled !== link) { + al.dataset.filled = link; al.innerHTML = ''; + for (const a of ANGLES) { + const url = angleLink(link, a.angle); + const hasVideo = VIDEOS.some(v => v.angle === a.angle); + // one collapsed band per angle, like the banner kit: open the one you want + const row = document.createElement('details'); + row.className = 'angle-row pb-acc'; + row.innerHTML = '' + esc(a.name) + ' ' + esc(a.hook) + '' + + (hasVideo ? 'video' : '') + '' + + '
    ' + + '
    ' + esc(a.use) + '
    ' + + '' + esc(url) + '
    ' + + '
    Open' + + (navigator.share ? '' : '') + '
    '; // Share = the phone's share sheet; desktops have none, so no button + const [copyBtn, shareBtn] = row.querySelectorAll('button'); + copyBtn.onclick = async () => { try { await navigator.clipboard.writeText(url); status((a.name === 'General' ? 'Invite' : a.name) + ' link copied.', 'ok'); } catch (e) { status('Copy failed.', 'bad'); } }; + if (shareBtn) shareBtn.onclick = async () => { try { await navigator.share({ title: 'LinkSpin', text: a.hook, url }); } catch (e) { if (!(e && e.name === 'AbortError')) status('Could not open the share sheet.', 'bad'); } }; + al.appendChild(row); + } + } + // posts + const posts = $('promoPosts'); + if (posts && posts.dataset.filled !== link) { + posts.dataset.filled = link; posts.innerHTML = ''; + for (const p of POSTS) { + const text = fillLink(p.text, link); + const extras = []; + if (p.net === 'X') extras.push(linkBtn('https://twitter.com/intent/tweet?text=' + encodeURIComponent(text), 'Post on X')); + if (p.net === 'Facebook') extras.push(linkBtn('https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(angleLink(link, (/\{\{LINK:([a-z]+)\}\}/.exec(p.text) || [])[1] || '')), 'Share on Facebook')); + if (p.net === 'LinkedIn') extras.push(linkBtn('https://www.linkedin.com/sharing/share-offsite/?url=' + encodeURIComponent(angleLink(link, 'adspend')), 'Share on LinkedIn')); + if (p.net === 'Telegram') extras.push(linkBtn('https://t.me/share/url?url=' + encodeURIComponent(angleLink(link, (/\{\{LINK:([a-z]+)\}\}/.exec(p.text) || [])[1] || '')) + '&text=' + encodeURIComponent(text.replace(/\s*\{\{LINK[^}]*\}\}\s*$/, '').replace(/https?:\/\/\S+$/, '').trim()), 'Share on Telegram')); + posts.appendChild(block(text, '' + esc(p.label) + '', extras)); + } + } + // text a friend + const texts = $('promoTexts'); + if (texts && texts.dataset.filled !== link) { + texts.dataset.filled = link; texts.innerHTML = ''; + TEXTS.forEach((t, i) => { + const text = fillLink(t.text, link); + const enc = encodeURIComponent(text); + const tgUrl = angleLink(link, t.angle); + const tgLead = text.replace(/https?:\/\/\S+/, '').trim(); + const extras = [ + linkBtn('sms:?&body=' + enc, 'Text it', ''), + linkBtn('https://wa.me/?text=' + enc, 'WhatsApp'), + linkBtn('https://t.me/share/url?url=' + encodeURIComponent(tgUrl) + '&text=' + encodeURIComponent(tgLead), 'Telegram') + ]; + texts.appendChild(block(text, 'Text ' + (i + 1) + (t.angle ? ' · ' + esc(t.angle) + ' angle' : ' · general') + ' ' + esc(t.title) + '', extras)); + }); + } + // swipes + const sw = $('promoSwipeWrap'); + if (sw && sw.dataset.filled !== link) { + sw.dataset.filled = link; sw.innerHTML = ''; + for (const s of SWIPES) { + const text = 'Subject: ' + s.subject + '\n\n' + fillLink(s.body, link); + sw.appendChild(block(text, '' + esc(s.tier) + ' ' + esc(s.subject) + '')); + } + } + // hook videos + const vw = $('promoVideos'); + if (vw && vw.dataset.filled !== link) { + vw.dataset.filled = link; vw.innerHTML = ''; + for (const v of VIDEOS) { + const mlink = angleLink(link, v.angle); + const card = document.createElement('div'); card.className = 'pv'; + card.innerHTML = '
    ' + esc(v.angle) + ' angle ' + esc(v.title) + '
    ' + esc(v.hook) + '
    ' + + '' + + '

    Matched link: ' + esc(mlink) + '

    '; + const row = document.createElement('p'); row.className = 'pb-actions'; + row.appendChild(copyBtn(mlink, 'Copy matched link')); + row.appendChild(linkBtn(VID_BASE + v.angle + '.mp4', 'Download 16:9')); + row.appendChild(linkBtn(VID_BASE + v.angle + '-portrait.mp4', 'Download 9:16')); + card.appendChild(row); + const cap = fillLink(v.caption, link); + const extras = [ + linkBtn('https://twitter.com/intent/tweet?text=' + encodeURIComponent(cap), 'Post on X'), + linkBtn('https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(mlink), 'Share on Facebook'), + linkBtn('https://t.me/share/url?url=' + encodeURIComponent(mlink) + '&text=' + encodeURIComponent(cap.replace(/https?:\/\/\S+$/, '').trim()), 'Telegram'), + linkBtn('https://wa.me/?text=' + encodeURIComponent(cap), 'WhatsApp') + ]; + card.appendChild(block(cap, 'Caption', extras)); + vw.appendChild(card); + } + } + // objections + const ob = $('promoObjections'); + if (ob && ob.dataset.filled !== link) { + ob.dataset.filled = link; ob.innerHTML = ''; + for (const o of OBJECTIONS) { + const d = document.createElement('details'); d.className = 'obj'; + const s = document.createElement('summary'); s.textContent = o.q; d.appendChild(s); + const body = document.createElement('div'); body.className = 'obj-body'; + const t = document.createElement('div'); t.className = 'obj-truth'; + t.innerHTML = 'The truth

    ' + esc(o.truth) + '

    '; body.appendChild(t); + const say = fillLink(o.say, link); + const sayEl = block(say, 'Say this'); + sayEl.classList.add('obj-say'); body.appendChild(sayEl); + d.appendChild(body); ob.appendChild(d); + } + } + } + + return { fill, POSTS, TEXTS, SWIPES, OBJECTIONS, VIDEOS }; +})(); diff --git a/public/assets/shorts.js b/public/assets/shorts.js new file mode 100644 index 0000000..7743d27 --- /dev/null +++ b/public/assets/shorts.js @@ -0,0 +1,75 @@ +// Paid shorts: a full-screen vertical feed over the video-ad inventory. You must +// watch each short's required time (server-clock enforced, no seek) to earn, +// then advance to the next. Reuses /api/my/videos + /api/my/videowatch. +(function () { + const $ = id => document.getElementById(id); + const st = { token: null, secs: 0, maxSeen: 0, credited: false, done: false, skips: 0 }; + function j(url, body) { + return fetch(url, body + ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } + : undefined).then(r => r.json()); + } + async function load() { + st.token = null; st.maxSeen = 0; st.credited = false; st.done = false; + $('shOver').hidden = true; $('shCta').hidden = true; $('shNext').hidden = true; + let r = null; + // signed-out visitors get the sign-in note without a 401 round trip + let me = null; try { me = await j('/api/me'); } catch (e) {} + if (me && me.signedIn && me.email) { try { r = await j('/api/my/videos?orientation=portrait'); } catch (e) {} } + if (!r || r.error) { $('shVideo').hidden = true; $('shMsg').hidden = false; $('shMsg').textContent = 'Sign in on the dashboard to watch shorts and earn.'; return; } + $('shStat').textContent = 'today: ' + (r.status.count || 0) + ' / ' + r.status.cap; + if (!r.ad) { + $('shVideo').hidden = true; $('shMsg').hidden = false; + $('shMsg').textContent = r.status.left <= 0 ? 'That\'s today\'s shorts. Come back tomorrow.' : 'No shorts in rotation right now. Check back soon.'; + return; + } + st.token = r.token; st.secs = r.ad.watchSecs; st.adId = r.ad.id; + const v = $('shVideo'); + $('shMsg').hidden = true; v.hidden = false; + v.src = r.ad.videoUrl; v.currentTime = 0; + // safety net: this reel is portrait-only. If a landscape video slips through, skip to the next. + v.onloadedmetadata = () => { + if (v.videoWidth && v.videoHeight && v.videoWidth > v.videoHeight) { + if (st.skips++ < 4) { load(); return; } + v.hidden = true; $('shMsg').hidden = false; $('shMsg').textContent = 'No shorts in rotation right now. Check back soon.'; + } else { st.skips = 0; } + }; + $('shTitle').textContent = r.ad.title || ''; + $('shCta').href = r.ad.ctaUrl; $('shCta').textContent = r.ad.ctaLabel || 'Learn more'; + $('shOver').hidden = false; $('shCta').hidden = false; + v.onseeking = () => { if (v.currentTime > st.maxSeen + 0.5) v.currentTime = st.maxSeen; }; + v.ontimeupdate = () => { + if (v.currentTime > st.maxSeen) st.maxSeen = v.currentTime; + const left = Math.max(0, Math.ceil(st.secs - st.maxSeen)); + $('shTimer').textContent = left > 0 ? 'Watch ' + left + 's more to earn' : 'Earned — swipe to the next'; + if (!st.done && st.maxSeen >= st.secs) { st.done = true; credit(); } + }; + v.play().catch(() => {}); + } + async function credit() { + if (st.credited) return; st.credited = true; + try { + const r = await j('/api/my/videowatch', { token: st.token }); + if (r.credited) { $('shStat').textContent = 'today: ' + (r.status.count || 0) + ' / ' + (r.status.cap || 0) + ' · +' + r.credited; } + } catch (e) {} + $('shNext').hidden = false; + } + $('shNext').addEventListener('click', load); + $('shReport').addEventListener('click', e => { + e.preventDefault(); if (!st.adId) return; + const reason = (prompt('Report this short: broken, inappropriate, spam, scam, or other', 'inappropriate') || '').trim().toLowerCase(); + if (!reason) return; + fetch('/api/report-ad', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ campaignId: st.adId, reason }) }) + .then(() => { $('shReport').textContent = '✓ reported — thanks'; }).catch(() => {}); + }); + // presence enforcement: pause when the tab/window loses focus, resume on return + document.addEventListener('visibilitychange', () => { + const v = $('shVideo'); if (!v || !v.src) return; + if (document.hidden) v.pause(); else if (!st.done) v.play().catch(() => {}); + }); + window.addEventListener('blur', () => { const v = $('shVideo'); if (v && v.src) v.pause(); }); + window.addEventListener('focus', () => { const v = $('shVideo'); if (v && v.src && !st.done) v.play().catch(() => {}); }); + // tap the video to pause/resume + $('shVideo').addEventListener('click', () => { const v = $('shVideo'); if (v.paused) v.play(); else v.pause(); }); + load(); +})(); diff --git a/public/assets/site.css b/public/assets/site.css new file mode 100644 index 0000000..bb946d5 --- /dev/null +++ b/public/assets/site.css @@ -0,0 +1,750 @@ +/* LinkSpin visual system v4 — built to Marty's picked reference + (Behance 243562211 "ChainLock"): near-black ground, ONE neon-mint accent, + sweeping light-trail hero, glowing icon plates, mockup-anchored sections, + ghost wordmark footer. Discipline over decoration. */ +:root{ + --ground:#040807; --ground2:#071009; + --panel:rgba(16,28,24,.55); --panel-solid:#0b1512; + --line:rgba(84,150,128,.16); --line-strong:rgba(84,150,128,.36); + --ink:#eef7f3; --muted:#8ba69c; + --mint:#43e8c3; --mint-hi:#8ffbe3; --mint-ink:#03211a; + --mint-soft:rgba(67,232,195,.09); --bad:#ff8f7d; + --cyan:#54ccff; --violet:#9d7dff; --amber:#ffb238; + --mono:"Consolas","JetBrains Mono",monospace; + --disp:"Sora","Segoe UI",system-ui,sans-serif; + --radius:18px; +} +*{box-sizing:border-box} +[hidden]{display:none!important} /* beats any display: set by a class (chat panel bug) */ +html{scroll-behavior:smooth} +body{margin:0;background:var(--ground);color:var(--ink);font:16px/1.65 "Segoe UI",system-ui,sans-serif;overflow-x:hidden} +/* topo-contour ground texture, very faint */ +body::before{content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;opacity:.5; + background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='560' height='560' viewBox='0 0 560 560'%3E%3Cg fill='none' stroke='%2343e8c3' stroke-opacity='.05'%3E%3Cpath d='M60 280c40-90 160-130 220-90s60 150 160 150 120-90 120-90'/%3E%3Cpath d='M40 340c60-110 190-160 260-110s60 170 180 170'/%3E%3Cpath d='M20 400c80-130 220-190 300-130s60 190 200 190'/%3E%3Cpath d='M80 220c30-70 130-100 180-70s50 120 130 120 100-70 100-70'/%3E%3C/g%3E%3C/svg%3E")} +a{color:var(--mint);text-decoration:none} +a:hover{text-decoration:underline} +.wrap{max-width:1100px;margin:0 auto;padding:0 22px} +h1,h2,h3{font-family:var(--disp);letter-spacing:-.015em} +/* ── nav: capsule pill ─────────────────────────────── */ +nav{position:sticky;top:0;z-index:10;background:rgba(4,8,7,.7);backdrop-filter:blur(16px); + -webkit-backdrop-filter:blur(16px);border-bottom:1px solid var(--line)} +nav .wrap{display:flex;align-items:center;gap:20px;min-height:66px;flex-wrap:wrap;padding-top:8px;padding-bottom:8px} +.logo{font-family:var(--disp);font-weight:800;font-size:20px;color:var(--ink)} +.logo b{color:var(--mint)} +.logo-wrap{display:flex;flex-direction:column;gap:3px;min-width:0} +.byline{display:block;font-size:10.5px;line-height:1.2;letter-spacing:.05em;color:var(--muted);white-space:nowrap} +.byline b{color:var(--ink);font-weight:600} +.bo-side .byline{padding:4px 8px 10px;white-space:normal} +@media (max-width:560px){nav .byline{font-size:9.5px}} +nav .links{display:flex;gap:4px;flex:1;flex-wrap:wrap;background:rgba(16,28,24,.7);border:1px solid var(--line); + border-radius:999px;padding:5px 8px;width:fit-content;flex:0 1 auto;margin:0 auto} +nav .links a{color:var(--muted);font-size:13.5px;font-weight:600;padding:7px 14px;border-radius:999px} +nav .links a:hover{color:var(--ink);text-decoration:none} +nav .links a.active{color:var(--mint);background:rgba(67,232,195,.1)} +#navWallet{font-size:13px;margin-left:auto} +.rehearsal{background:rgba(67,232,195,.07);border-bottom:1px solid var(--line);color:var(--muted); + text-align:center;font-size:12.5px;padding:7px 12px} +.rehearsal b{color:var(--mint)} +/* ── buttons ───────────────────────────────────────── */ +.btn{display:inline-block;background:var(--mint);color:var(--mint-ink);border:0;border-radius:999px; + padding:13px 26px;font-weight:700;font-size:15px;cursor:pointer;font-family:var(--disp); + box-shadow:0 4px 30px rgba(67,232,195,.35);transition:transform .16s ease,box-shadow .16s ease,background .16s ease} +.btn:hover{transform:translateY(-2px);background:var(--mint-hi);box-shadow:0 8px 44px rgba(67,232,195,.5);text-decoration:none} +.btn.sec{background:transparent;color:var(--mint);border:1px solid rgba(67,232,195,.6);box-shadow:none} +.btn.sec:hover{background:rgba(67,232,195,.08);box-shadow:0 4px 26px rgba(67,232,195,.2)} +.btn.small{padding:8px 17px;font-size:13px} +.btn:disabled{opacity:.45;cursor:default;transform:none;box-shadow:none} +/* ── hero: centered, light-trail set-piece ─────────── */ +.hero{position:relative;text-align:center;padding:120px 0 84px;overflow:visible} +.hero h1{font-size:clamp(36px,5.4vw,62px);font-weight:700;line-height:1.08;margin:0 auto 18px;max-width:820px;text-wrap:balance} +.hero h1 em{font-style:normal;color:var(--mint)} +.hero p.lead{color:var(--muted);font-size:17.5px;max-width:600px;margin:0 auto 32px} +.hero p.lead b{color:var(--ink)} +.hero .ctas{display:flex;gap:14px;justify-content:center;flex-wrap:wrap} +/* neon arcs behind the hero text */ +.arcs{position:absolute;inset:-40px 0 0 0;z-index:-1;pointer-events:none} +.arcs svg{width:100%;height:100%;overflow:visible} +.arc{fill:none;stroke:url(#arcGrad);stroke-width:5;stroke-linecap:round;filter:url(#arcGlow)} +.arc2{fill:none;stroke:url(#arcGrad);stroke-width:3;stroke-linecap:round;filter:url(#arcGlow);opacity:.8} +/* falling glow streaks */ +.streak{position:absolute;top:-140px;width:1.5px;height:110px;pointer-events:none; + background:linear-gradient(180deg,transparent,rgba(143,251,227,.8));border-radius:2px;opacity:0} +@media(prefers-reduced-motion:no-preference){ + .streak{animation:fall 7s linear infinite} + @keyframes fall{0%{transform:translateY(0);opacity:0}12%{opacity:.7}55%{opacity:.5}75%{transform:translateY(72vh);opacity:0}100%{transform:translateY(72vh);opacity:0}} +} +/* ── counters ──────────────────────────────────────── */ +.stats{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;margin:60px auto 0;max-width:900px} +@media(min-width:760px){.stats{grid-template-columns:repeat(4,1fr)}} +.stat{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:18px 12px;text-align:center} +.stat .n{font-family:var(--disp);font-size:30px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--mint)} +.stat .l{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;margin-top:3px} +/* ── sections ──────────────────────────────────────── */ +section{padding:64px 0 8px} +.sectionhead{text-align:center;max-width:640px;margin:0 auto 40px} +.cmp{width:100%;border-collapse:separate;border-spacing:0;font-size:15px;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden} +.cmp th,.cmp td{padding:13px 16px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left} +.cmp th{font-family:var(--disp);font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)} +.cmp th:last-child,.cmp td:last-child{color:var(--ink);background:rgba(67,232,195,.06)} +.cmp td:first-child{font-weight:700;white-space:nowrap} +.cmp td:nth-child(2){color:var(--muted)} +.cmp tr:last-child td{border-bottom:0} +@media (max-width:640px){.cmp td:first-child{white-space:normal}.cmp th,.cmp td{padding:10px 10px;font-size:14px}} +h2{font-size:clamp(26px,3.2vw,36px);font-weight:700;margin:0 0 12px;text-wrap:balance} +.sectionhead p{color:var(--muted);font-size:15.5px;margin:0} +h3{font-size:16.5px;margin:0 0 8px;font-weight:700} +.muted{color:var(--muted)} +.small{font-size:13.5px} +.mono{font-family:var(--mono)} +/* icon-plate feature cards */ +.plates{display:grid;gap:16px;grid-template-columns:1fr} +@media(min-width:760px){.plates{grid-template-columns:1fr 1fr 1fr}} +.platecard{background:linear-gradient(170deg,rgba(22,38,32,.6),rgba(10,18,15,.7));border:1px solid var(--line); + border-radius:var(--radius);padding:26px 22px;text-align:center;transition:border-color .2s ease,transform .2s ease} +.platecard:hover{border-color:var(--line-strong);transform:translateY(-4px)} +.plate{width:88px;height:88px;margin:0 auto 18px;border-radius:22px;display:grid;place-items:center; + background:linear-gradient(160deg,#12241e,#0a1411);border:1px solid var(--line-strong); + box-shadow:0 0 34px rgba(67,232,195,.15),inset 0 1px 0 rgba(143,251,227,.15)} +.plate svg{width:38px;height:38px;stroke:var(--mint);fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round} +.platecard p{color:var(--muted);font-size:14px;margin:0} +/* two-col flank layout (icons around a central mockup) */ +.flank{display:grid;gap:26px;align-items:center;grid-template-columns:1fr} +@media(min-width:920px){.flank{grid-template-columns:1fr 1.15fr 1fr}} +.flankitem{margin:0 0 26px} +.flankitem .plate{width:52px;height:52px;border-radius:14px;margin:0 0 12px} +.flankitem .plate svg{width:24px;height:24px} +.flankitem p{color:var(--muted);font-size:13.5px;margin:0} +/* ledger mockup frame */ +.mockup{background:linear-gradient(170deg,#0e1c17,#080f0c);border:1px solid var(--line-strong);border-radius:20px; + box-shadow:0 30px 80px rgba(0,0,0,.6),0 0 60px rgba(67,232,195,.08);overflow:hidden} +.mockup .bar{display:flex;gap:6px;align-items:center;padding:11px 14px;border-bottom:1px solid var(--line)} +.mockup .bar i{width:9px;height:9px;border-radius:50%;background:#1e3a31;display:inline-block} +.mockup .bar .addr{font-family:var(--mono);font-size:11px;color:var(--muted);margin-left:8px;background:rgba(4,8,7,.6); + border-radius:999px;padding:3px 12px} +.mockup .body{padding:6px 0} +.mockup .mrow{display:flex;justify-content:space-between;gap:10px;padding:9px 16px;font-family:var(--mono); + font-size:12px;border-bottom:1px solid rgba(84,150,128,.08)} +.mockup .mrow span:last-child{color:var(--mint)} +.mockup .mrow.dim span:last-child{color:var(--muted)} +/* 2-col story block: iso illustration + checklist */ +.story{display:grid;gap:36px;align-items:center;grid-template-columns:1fr} +@media(min-width:880px){.story{grid-template-columns:1fr 1fr}} +.checks{list-style:none;padding:0;margin:14px 0 0} +.checks li{padding:7px 0 7px 30px;position:relative;color:var(--muted);font-size:14.5px} +.checks li::before{content:"✓";position:absolute;left:0;top:6px;width:20px;height:20px;border-radius:50%; + background:var(--mint-soft);color:var(--mint);font-size:12px;font-weight:700;display:grid;place-items:center; + border:1px solid rgba(67,232,195,.35)} +.chips{display:flex;gap:8px;margin:14px 0 4px;flex-wrap:wrap} +.chip-t{border:1px solid var(--line-strong);border-radius:999px;padding:5px 16px;font-size:12.5px;color:var(--muted);font-family:var(--disp);font-weight:600} +.chip-t.on{background:var(--mint);color:var(--mint-ink);border-color:var(--mint)} +.iso{filter:drop-shadow(0 24px 50px rgba(0,0,0,.5))} +/* level cycler: light the paying generation */ +#genViz .edge path{stroke:#22523f;transition:stroke .5s ease} +#genViz .node-o{fill:#122e25;stroke:#2f6b55;stroke-width:1.3;transition:stroke .5s ease,fill .5s ease} +#genViz .node-d{fill:#3f8f74;transition:fill .5s ease} +#genViz[data-lvl="1"] .e1 path,#genViz[data-lvl="2"] .e2 path,#genViz[data-lvl="3"] .e3 path{stroke:#43e8c3} +#genViz[data-lvl="1"] .g1 .node-o,#genViz[data-lvl="2"] .g2 .node-o,#genViz[data-lvl="3"] .g3 .node-o{stroke:#43e8c3;fill:#11362b} +#genViz[data-lvl="1"] .g1 .node-d,#genViz[data-lvl="2"] .g2 .node-d,#genViz[data-lvl="3"] .g3 .node-d{fill:#43e8c3} +.chips button{cursor:pointer;background:transparent;font:inherit} +.chips button.chip-t{border:1px solid var(--line-strong);color:var(--muted)} +.chips button.chip-t.on{background:var(--mint);color:var(--mint-ink);border-color:var(--mint)} +/* pricing tiles: mint discipline */ +.tiles{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(185px,1fr));align-items:stretch} +.tile{position:relative;background:linear-gradient(170deg,rgba(22,38,32,.55),rgba(10,18,15,.65));border:1px solid var(--line); + border-radius:var(--radius);padding:26px 18px;text-align:center;display:flex;flex-direction:column;gap:6px; + transition:transform .2s ease,border-color .2s ease} +.tile:hover{transform:translateY(-5px);border-color:var(--line-strong)} +.tile .name{font-family:var(--disp);font-weight:700;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em} +.tile .price{font-family:var(--disp);font-size:36px;font-weight:700} +.tile .cr{color:var(--mint);font-weight:700;font-size:14.5px} +.tile .bonus{font-size:12px;color:var(--mint-hi)} +.tile .pol{font-family:var(--mono);font-size:12px;color:var(--muted);flex:1} +.tile.hot{border:1px solid rgba(67,232,195,.65);box-shadow:0 0 44px rgba(67,232,195,.14)} +.tile.hot::before{content:"MOST POPULAR";position:absolute;top:-11px;left:50%;transform:translateX(-50%); + background:var(--mint);color:var(--mint-ink);font-family:var(--disp);font-size:10px;font-weight:800; + letter-spacing:.12em;border-radius:999px;padding:4px 14px} +/* split strip: mint opacities */ +.split{display:flex;gap:8px;margin:18px 0} +.split div{border-radius:12px;padding:16px 6px;text-align:center;font-size:13px;font-weight:700;font-family:var(--disp); + border:1px solid var(--line-strong);color:var(--mint)} +.split .s50{flex:5;background:rgba(67,232,195,.16);box-shadow:0 0 30px rgba(67,232,195,.12)} +.split .s20{flex:2;background:rgba(67,232,195,.09)} +.split .s10{flex:1;background:rgba(67,232,195,.05)} +.split .sa{flex:2;background:rgba(139,166,156,.08);color:var(--muted)} +section[id]{scroll-margin-top:84px} /* anchored menu targets clear the sticky nav */ +/* ticker */ +.ticker{overflow:hidden;contain:layout paint;border-top:1px solid var(--line);border-bottom:1px solid var(--line); + background:rgba(67,232,195,.04);white-space:nowrap;padding:10px 0; + -webkit-mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent); + mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent)} +.ticker .inner{display:inline-block;font-family:var(--mono);font-size:12.5px;padding-left:100%;animation:tick 120s linear infinite} +.ticker .inner span{margin-right:48px;color:var(--muted)} +.ticker .inner span b{color:var(--mint);font-weight:600} +@keyframes tick{to{transform:translateX(-100%)}} +@media(prefers-reduced-motion:reduce){.ticker .inner{animation:none;padding-left:0}} +/* cards / tables / feed (inner pages) */ +.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:24px;margin:0 0 16px} +.card:hover{border-color:var(--line-strong)} +.grid{display:grid;gap:16px} +.grid>*,.flank>*,.story>*{min-width:0} /* let grid children shrink so inner tablewraps scroll instead of widening the page */ +@media(min-width:760px){.grid.c3{grid-template-columns:1fr 1fr 1fr}.grid.c2{grid-template-columns:1fr 1fr}} +table{width:100%;border-collapse:collapse;font-size:15px} +th,td{text-align:left;padding:12px 14px;border-bottom:1px solid var(--line);vertical-align:middle} +th{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.1em;font-family:var(--disp)} +td.num,th.num{font-variant-numeric:tabular-nums} +tr:last-child td{border-bottom:0} +.tablewrap{overflow-x:auto} +.feed{font-family:var(--mono);font-size:13.5px;line-height:1.7} +.feed .row{padding:9px 14px;border-bottom:1px solid var(--line);display:flex;gap:10px;align-items:baseline;flex-wrap:wrap} +.feed .row:first-child{background:var(--mint-soft)} +.feed .row .when{font-family:var(--mono);font-size:11px;color:var(--muted);white-space:nowrap;min-width:92px} +.feed .t-Purchase,.feed .t-AwardPaid{color:var(--mint-hi)} +.feed .t-TierPaid{color:var(--mint)} +.feed .t-AdminPaid{color:var(--muted)} +.feed .tx a{color:var(--muted);font-size:12px} +.badge{display:inline-block;background:var(--mint-soft);color:var(--mint);border:1px solid rgba(67,232,195,.35); + border-radius:999px;padding:3px 13px;font-size:12px;font-weight:700} +.spend-banner{background:linear-gradient(160deg,rgba(242,201,76,.14),rgba(242,201,76,.04));border-color:rgba(242,201,76,.55);text-align:center;padding:22px 20px} +.spend-banner .lbl{font-family:var(--disp);font-size:13px;letter-spacing:.14em;text-transform:uppercase;color:#f2c94c} +.spend-banner .big{font-family:var(--disp);font-size:clamp(40px,6vw,64px);font-weight:800;color:#f2c94c;line-height:1.05;margin:6px 0 4px;font-variant-numeric:tabular-nums} +.spend-banner .big .unit{font-size:20px;font-weight:700;color:var(--ink)} +.spend-banner .sub{color:var(--ink);font-size:15px} +.spend-banner .rates{display:flex;gap:8px 18px;flex-wrap:wrap;justify-content:center;margin-top:12px;font-size:12.5px;color:var(--muted)} +.spend-banner .rates b{color:var(--ink)} +.boot-spin{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;min-height:60vh} +.boot-spin .ring{width:46px;height:46px;border-radius:50%;border:4px solid rgba(67,232,195,.18);border-top-color:var(--mint);animation:bootspin .9s linear infinite} +@keyframes bootspin{to{transform:rotate(360deg)}} +.bo-top{flex-wrap:wrap} +.ad-strip{flex:1 1 100%;font-size:13.5px;padding:8px 0 2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ad-strip a{color:var(--ink)} +.ad-strip b{color:var(--mint)} +.ad-foot{text-align:center} +.pv{border:1px solid var(--line);border-radius:14px;padding:16px 18px;margin:0 0 16px;background:rgba(4,8,7,.35)} +.pv-head b{display:block;font-size:18px;margin:4px 0 2px} +.done-big{display:flex;flex-direction:column;align-items:center;gap:6px;padding:26px 16px;text-align:center} +.done-big .tick{width:84px;height:84px;border-radius:50%;display:grid;place-items:center;font-size:48px;font-weight:800;color:var(--mint-ink);background:var(--mint);box-shadow:0 0 0 10px rgba(67,232,195,.15),0 10px 30px rgba(67,232,195,.35)} +.done-big b{font-family:var(--disp);font-size:22px;margin-top:8px} +.badge.amber{background:rgba(139,166,156,.1);border-color:rgba(139,166,156,.4);color:var(--muted)} +/* faq */ +details{border:1px solid var(--line);border-radius:14px;margin:0 0 10px;background:var(--panel)} +details[open]{border-color:rgba(67,232,195,.45)} +summary{cursor:pointer;padding:16px 20px;font-weight:700;font-family:var(--disp);font-size:15px;list-style:none} +summary::before{content:"+";color:var(--mint);font-weight:800;margin-right:12px} +details[open] summary::before{content:"−"} +details p{margin:0;padding:0 20px 18px 42px;color:var(--muted);font-size:14.5px} +/* closing cta card */ +.closer{position:relative;display:grid;gap:26px;align-items:center;grid-template-columns:1fr; + padding:44px 36px;border-radius:24px;overflow:hidden;margin:80px 0 0; + background:linear-gradient(160deg,#0d1d17,#060c0a);border:1px solid var(--line-strong)} +@media(min-width:880px){.closer{grid-template-columns:1.2fr .8fr}} +.closer::before{content:"";position:absolute;width:460px;height:460px;right:-140px;top:-260px;border-radius:50%; + background:radial-gradient(circle,rgba(67,232,195,.2),transparent 65%);pointer-events:none} +.closer h2{margin:0 0 12px} +/* footer + ghost wordmark */ +footer{border-top:1px solid var(--line);margin-top:90px;padding:34px 0 0;color:var(--muted);font-size:13.5px;overflow:hidden} +.ghost{font-family:var(--disp);font-weight:800;font-size:clamp(64px,13vw,170px);line-height:.9;text-align:center; + margin:34px 0 -20px;letter-spacing:-.02em;user-select:none; + background:linear-gradient(180deg,rgba(67,232,195,.14),transparent 78%); + -webkit-background-clip:text;background-clip:text;color:transparent;white-space:nowrap} +/* misc */ +#status{position:fixed;left:50%;transform:translateX(-50%);bottom:22px;background:var(--panel-solid); + border:1px solid var(--line-strong);border-radius:14px;padding:13px 22px;font-size:14px;max-width:90vw;z-index:30; + box-shadow:0 12px 40px rgba(0,0,0,.55)} +#status.ok{border-color:var(--mint)} +#status.bad{border-color:var(--bad)} +input,select,textarea{background:rgba(4,8,7,.65);border:1px solid var(--line-strong);color:var(--ink);border-radius:11px; + padding:11px 14px;font-size:14.5px;font-family:inherit} +input[type=range]{padding:0;border:0;background:transparent;accent-color:var(--mint);height:28px;vertical-align:middle} +input:focus,select:focus,textarea:focus{border-color:var(--mint)} +textarea{resize:vertical;font:inherit} +:focus-visible{outline:2px solid var(--mint);outline-offset:2px} +.hero-note{font-family:var(--mono);font-size:12px;color:var(--muted);margin-top:26px} +/* ── member back-office shell ─────────────────────────── */ +.bo-body{background:var(--ground)} +.bo{display:grid;grid-template-columns:236px 1fr;min-height:100vh} +.bo-side{position:sticky;top:0;height:100vh;overflow-y:auto;scrollbar-width:none;-ms-overflow-style:none;display:flex;flex-direction:column;gap:22px; + padding:22px 16px;background:rgba(10,18,15,.92);border-right:1px solid var(--line)} +.bo-side .logo{font-size:19px;padding:0 8px} +.bo-menu{display:flex;flex-direction:column;gap:4px} +.bo-menu button{display:flex;align-items:center;gap:11px;background:transparent;border:0;color:var(--muted); + font:600 14px var(--disp);padding:11px 12px;border-radius:11px;cursor:pointer;text-align:left; + border-left:3px solid transparent} +.bo-menu button svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto} +.bo-menu button:hover{color:var(--ink);background:rgba(67,232,195,.05)} +.bo-menu button.on{color:var(--mint);background:rgba(67,232,195,.09);border-left-color:var(--mint)} +.bo-links{display:flex;flex-direction:column;gap:2px;padding:14px 12px;border-top:1px solid var(--line)} +.bo-cap{font-family:var(--mono);font-size:10.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-bottom:6px} +.bo-links a{color:var(--muted);font-size:13.5px;padding:5px 0} +.bo-links a:hover{color:var(--ink);text-decoration:none} +.bo-foot{margin-top:auto;padding:14px 12px 0;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px;overflow-wrap:anywhere} +.bo-main{min-width:0;display:flex;flex-direction:column} +.bo-side::-webkit-scrollbar{width:0;height:0;display:none} +.bo-pagefoot{margin-top:auto;padding:22px 26px;border-top:1px solid var(--line);display:flex;gap:20px;flex-wrap:wrap;justify-content:center;font-size:13px} +.bo-pagefoot a{color:var(--muted)} +.bo-pagefoot a:hover{color:var(--ink);text-decoration:none} +.bo-top{display:flex;align-items:center;gap:16px;padding:16px 26px;border-bottom:1px solid var(--line); + background:rgba(6,10,8,.75);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);position:sticky;top:0;z-index:5} +.bo-top h2{font-size:20px} +#boBurger{display:none;background:transparent;border:1px solid var(--line-strong);color:var(--ink); + border-radius:9px;font-size:17px;padding:5px 11px;cursor:pointer} +.bo-rehearsal{margin-left:auto;color:var(--muted)} +.bo-rehearsal b{color:var(--mint)} +.bo-content{padding:26px;max-width:1060px;width:100%} +@media(max-width:959px){ + .bo{grid-template-columns:1fr} + .bo-side{position:fixed;left:0;top:0;bottom:0;width:250px;z-index:20;transform:translateX(-100%); + transition:transform .22s ease;height:100dvh} + .bo.side-open .bo-side{transform:none;box-shadow:0 0 60px rgba(0,0,0,.6)} + #boBurger{display:block} + .bo-content{padding:18px} +} +/* ── promo banners ── */ +.promo-banners{display:flex;flex-direction:column;gap:10px;margin-top:12px} +.pb-acc{margin:0} +.pb-acc>summary{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:13px 16px;font-size:14.5px} +.pb-acc>summary::before{content:none} +.pb-acc>summary>span:first-child::before{content:"+";color:var(--mint);font-weight:800;margin-right:10px} +.pb-acc[open]>summary>span:first-child::before{content:"−"} +.pb-acc .pb-count{font-family:var(--mono);font-size:11px;color:var(--muted);white-space:nowrap} +.pb-acc .pb-grid{display:flex;flex-direction:column;gap:14px;padding:0 16px 16px} +.pb-item img{max-width:100%;border-radius:8px;border:1px solid var(--line-strong);display:block} +.pb-item .pb-row{display:flex;gap:10px;align-items:center;margin-top:6px;flex-wrap:wrap} +.pb-item .pb-size{font-family:var(--mono);font-size:11px;color:var(--muted)} +/* ── featured day-slot picker ── */ +.feat-days{display:flex;gap:8px;flex-wrap:wrap} +.feat-day{flex:0 0 auto;min-width:82px;padding:8px 10px;border:1px solid var(--line-strong);border-radius:10px; + background:var(--panel);cursor:pointer;text-align:center} +.feat-day.on{border-color:var(--mint);box-shadow:0 0 0 1px var(--mint)} +.feat-day.full{opacity:.45;cursor:not-allowed} +.feat-day .fd-day{font-weight:700;font-size:12px} +.feat-day .fd-occ{font-family:var(--mono);font-size:11px;color:var(--muted)} +.feat-day.open2 .fd-occ{color:var(--mint)} +/* ── featured rotation strip ── */ +.feat-top{max-width:760px;margin:16px auto;border:1px solid rgba(255,177,56,.5)} +.feat-strip{display:flex;justify-content:center;margin-top:10px} +.feat-strip a{max-width:728px;width:100%;display:flex;justify-content:space-between;gap:10px;padding:11px 14px;border-radius:10px; + background:var(--panel);border:1px solid #ffb238;color:var(--ink);text-decoration:none;font-weight:600} +.feat-strip a:hover{border-color:#ffd15c;box-shadow:0 0 0 1px rgba(255,177,56,.4)} +.feat-strip .by{font-size:12px;color:var(--muted);font-weight:500;white-space:nowrap} +.feat-strip .by{font-size:12px;color:var(--muted);font-weight:500;white-space:nowrap} +/* ── achievement badges ── */ +.badges{display:flex;gap:18px;flex-wrap:wrap;margin-top:12px} +.badge-a{display:flex;flex-direction:column;align-items:center;gap:6px;width:150px;text-align:center} +.badge-a .badge-img{width:150px;height:150px;border-radius:14px;object-fit:cover;border:1px solid var(--line-strong)} +.badge-a.locked .badge-img{filter:grayscale(.85) brightness(.5);opacity:.7} +.badge-a .medal{width:76px;height:76px;border-radius:50%;display:grid;place-items:center;position:relative; + background:radial-gradient(circle at 50% 35%,rgba(67,232,195,.28),rgba(9,26,19,.9)); + border:2px solid var(--mint);box-shadow:0 0 18px rgba(67,232,195,.3)} +.badge-a.locked .medal{background:rgba(139,166,156,.08);border-color:var(--line-strong);box-shadow:none;filter:grayscale(1);opacity:.5} +.badge-a .medal svg{width:38px;height:38px;stroke:var(--mint);fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round} +.badge-a.locked .medal svg{stroke:var(--muted)} +.badge-a .bl{font-size:12px;font-weight:700;line-height:1.2} +.badge-a .bs{font-size:10.5px;color:var(--muted)} +.badge-a .share{font-size:11px;color:var(--mint);cursor:pointer;background:none;border:0;padding:0;text-decoration:underline} +/* ── "Your next move": milestone stepper card ── */ +.next-card{position:relative;overflow:hidden;border-color:rgba(67,232,195,.28)} +.next-card::before{content:"";position:absolute;inset:0;pointer-events:none;background: + radial-gradient(420px 150px at 10% 0%,rgba(67,232,195,.12),transparent 70%), + radial-gradient(360px 130px at 90% 100%,rgba(157,125,255,.09),transparent 70%)} +.nc-head{display:flex;gap:14px;align-items:flex-start;position:relative} +.nc-head .pl{flex:0 0 auto;width:44px;height:44px;border-radius:12px;display:grid;place-items:center; + background:rgba(67,232,195,.12);border:1px solid rgba(67,232,195,.35)} +.nc-head .pl svg{width:20px;height:20px;stroke:var(--mint);fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round} +.nc-steps{display:flex;margin-top:20px;position:relative;flex-wrap:wrap;row-gap:14px} +.nc-step{flex:1;min-width:88px;display:flex;flex-direction:column;align-items:center;gap:7px; + position:relative;text-align:center;padding:0 4px} +.nc-step::before{content:"";position:absolute;top:13px;left:-50%;width:100%;height:2px;background:var(--line-strong)} +.nc-step:first-child::before{display:none} +.nc-step.hit::before{background:linear-gradient(90deg,var(--mint),var(--mint-hi))} +.nc-step .dot{width:27px;height:27px;border-radius:50%;display:grid;place-items:center;font-size:12px; + font-weight:800;background:var(--panel-solid);border:2px solid var(--line-strong);color:var(--muted); + position:relative;z-index:1} +.nc-step.hit .dot{background:var(--mint);border-color:var(--mint);color:var(--mint-ink); + box-shadow:0 0 14px rgba(67,232,195,.35)} +.nc-step.cur .dot{border-color:var(--amber);color:var(--amber);box-shadow:0 0 12px rgba(255,178,56,.4)} +.nc-step .lb{font-size:12px;font-weight:700;line-height:1.25} +.nc-step .lb i{display:block;font-style:normal;font-weight:500;font-size:10.5px;color:var(--muted);margin-top:2px} +.nc-step.cur .lb{color:var(--amber)} +/* ── overview v3: stat cards w/ plates+chips, hand-rolled charts ── */ +.statx{display:flex;gap:14px;align-items:flex-start;background:var(--panel);border:1px solid var(--line); + border-radius:var(--radius);padding:18px;position:relative;overflow:hidden} +.statx .pl{flex:0 0 auto;width:46px;height:46px;border-radius:13px;display:grid;place-items:center; + background:rgba(67,232,195,.1);border:1px solid rgba(67,232,195,.3)} +.statx .pl svg{width:22px;height:22px;stroke:var(--mint);fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round} +.statx.c-cyan .pl{background:rgba(84,204,255,.1);border-color:rgba(84,204,255,.3)} +.statx.c-cyan .pl svg{stroke:var(--cyan)} +.statx.c-violet .pl{background:rgba(157,125,255,.12);border-color:rgba(157,125,255,.32)} +.statx.c-violet .pl svg{stroke:var(--violet)} +.statx.c-amber .pl{background:rgba(255,178,56,.1);border-color:rgba(255,178,56,.3)} +.statx.c-amber .pl svg{stroke:var(--amber)} +.statx .nv{font-family:var(--disp);font-size:26px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.1} +.statx .lb{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-top:2px} +.chip{display:inline-block;margin-top:7px;font-size:11.5px;font-weight:700;border-radius:999px;padding:2px 10px; + background:rgba(67,232,195,.1);color:var(--mint)} +.chip.flat{background:rgba(139,166,156,.12);color:var(--muted)} +.chip.warm{background:rgba(255,178,56,.12);color:var(--amber)} +.card-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px} +.card-head .sub{font-size:12px;color:var(--muted)} +/* bar chart (divs, real data) */ +.barchart{display:flex;align-items:flex-end;gap:6px;height:120px;margin-top:14px} +.barchart .bar{flex:1;min-width:6px;background:linear-gradient(180deg,var(--mint),rgba(67,232,195,.25)); + border-radius:5px 5px 2px 2px;position:relative;transition:height .5s ease} +.barchart .bar.alt{background:linear-gradient(180deg,var(--violet),rgba(157,125,255,.25))} +.barchart .bar:hover::after{content:attr(data-v);position:absolute;bottom:calc(100% + 4px);left:50%; + transform:translateX(-50%);background:var(--panel-solid);border:1px solid var(--line-strong);border-radius:7px; + padding:2px 8px;font-family:var(--mono);font-size:11px;white-space:nowrap;z-index:3} +.barchart .bar.empty{background:rgba(139,166,156,.15);height:4px!important} +.chart-x{display:flex;gap:6px;margin-top:6px} +.chart-x span{flex:1;text-align:center;font-family:var(--mono);font-size:10px;color:var(--muted);overflow:hidden;white-space:nowrap} +/* donut + ring (SVG) */ +.donut-wrap{display:flex;gap:22px;align-items:center;margin-top:10px;flex-wrap:wrap} +.donut-legend{display:flex;flex-direction:column;gap:8px;font-size:13px} +.donut-legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:8px} +.donut-center{font-family:var(--disp);font-weight:800} +/* ── login ad interstitial: sponsor opens in a new tab, timer runs here ── */ +.lgate{position:fixed;inset:0;z-index:90;display:flex;flex-direction:column;background:var(--ground)} +.lgate-bar{display:flex;align-items:center;gap:14px;padding:10px 16px;background:var(--panel-solid); + border-bottom:1px solid var(--line);flex-wrap:wrap} +.lg-brand{font-family:var(--disp);font-weight:800;font-size:15px;white-space:nowrap} +.lg-brand em{color:var(--mint);font-style:normal} +.lgate-note{flex:1;min-width:140px} +.lg-timer{font-weight:700;font-size:14px;border:1px solid var(--line-strong);border-radius:999px; + padding:5px 14px;min-width:150px;text-align:center} +.lg-timer.done{color:var(--mint);border-color:var(--mint)} +.lgate-body{flex:1;display:grid;place-items:center;padding:24px;overflow:auto} +.lgate-card{max-width:560px;text-align:center} +.lgate-card img{max-width:100%;max-height:50vh;border-radius:12px;border:1px solid var(--line-strong)} +#lgCreative .lg-linkcard{display:inline-block;background:var(--panel);border:1px solid var(--line-strong); + border-radius:14px;padding:22px 28px;font-family:var(--disp);font-weight:700;font-size:18px;overflow-wrap:anywhere} +/* ── welcome tour (gauntlet): framed line sites + countdown ── */ +.lgate-frame{flex:1;border:0;width:100%;background:#fff;min-height:0} +.ggmeta{padding:8px 16px;background:var(--panel-solid);border-bottom:1px solid var(--line)} +/* ── bio header (wall/profile page) ── */ +.bio-head{display:flex;gap:22px;align-items:center;flex-wrap:wrap;padding:8px 0 4px} +.bio-avatar{width:96px;height:96px;border-radius:50%;object-fit:cover;border:2px solid var(--mint);flex:0 0 auto} +.bio-badge{width:74px;height:74px;border-radius:12px;object-fit:cover;flex:0 0 auto;border:1px solid var(--line-strong)} +.btn.disabled{opacity:.45;pointer-events:none;filter:grayscale(.3)} +.bio-meta{flex:1;min-width:220px} +.bio-socials{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px} +.bio-video{margin:14px auto 8px;max-width:760px} +@media (max-width:720px){.bio-head{flex-direction:column;align-items:center;text-align:center;gap:14px}.bio-head .bio-avatar,.bio-head .bio-badge{margin:0 auto}.bio-meta{min-width:0;width:100%}.bio-socials{justify-content:center}.bio-qr{margin:4px auto 0}.bio-video .eyebrow{text-align:center}} +.bio-video iframe,.bio-video video{width:100%;aspect-ratio:16/9;border:1px solid var(--line-strong);border-radius:16px;background:#000;display:block} +.bio-socials a{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:700;color:var(--mint);border:1px solid var(--line-strong); + border-radius:999px;padding:4px 12px 4px 9px;text-decoration:none} +.bio-socials a svg{flex:0 0 auto} +.bio-socials a:hover{border-color:var(--mint)} +.bio-qr{flex:0 0 auto;background:var(--panel);border:1px solid var(--line-strong);border-radius:14px;padding:10px} +.bio-qr img{display:block;border-radius:8px;background:#eef7f3} +/* ── wall page ── */ +.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} +.wc-creative img{max-width:100%;border-radius:10px;border:1px solid var(--line-strong)} +.wc-action{margin-top:10px;min-height:32px;display:flex;justify-content:center;align-items:center} +.wc-check{color:var(--mint);font-weight:800;border:1px solid var(--mint);border-radius:999px;padding:4px 14px; + box-shadow:0 0 14px rgba(67,232,195,.3)} +.wc-timer{color:var(--amber);font-weight:700;font-family:var(--mono);font-size:13px} +/* ── 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{display:inline-block;font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em; + color:var(--mint);background:var(--mint-soft);border:1px solid var(--line-strong);border-radius:8px;padding:4px 10px;margin-bottom:10px} +.lin-row{display:grid;grid-template-columns:minmax(110px,1.2fr) minmax(0,2fr) auto auto auto auto;gap:6px 14px; + align-items:center;padding:9px 0;border-bottom:1px solid var(--line)} +.lin-row .nm{font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.lin-row .em{font-size:12px;color:var(--mint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.lin-row .id{font-family:var(--mono);font-size:11px;color:var(--muted)} +.lin-row .sp{display:block;font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .lin-row .em{min-width:0} +.lin-row .dt{font-size:12px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap} +.lin-row .chat-msg-btn{justify-self:end} +.lin-row .lin-act-btn{justify-self:end;white-space:nowrap} +.lin-act{display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:8px 10px 10px;margin:-1px 0 6px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.025);border-radius:0 0 10px 10px} +.lin-chip{font-size:12px;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:3px 10px;white-space:nowrap} .lin-chip b{font-weight:600;color:var(--ink);margin-right:5px} .lin-chip.on{border-color:rgba(67,232,195,.45);color:var(--ink)} .lin-chip.warn{border-color:rgba(255,209,92,.5)} +.lin-verdict{margin-left:auto;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#ffd15c} .lin-verdict.on{color:var(--mint)} +@media (max-width:560px){.lin-chip{white-space:normal}.lin-act{padding:8px 4px 10px}} +@media (max-width:560px){.lin-row{grid-template-columns:1fr auto;gap:2px 10px} + .lin-row .em{grid-column:1/2}.lin-row .dt{grid-column:1/2;text-align:left}.lin-row .chat-msg-btn{grid-row:1/3;grid-column:2} + .lin-row .lin-act-btn{grid-column:2;grid-row:3/5}.lin-row:not(:has(.chat-msg-btn)) .lin-act-btn{grid-row:1/3}} +/* ── 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; + padding:5px 11px;font-size:12.5px;cursor:pointer} +.ed-bar button:hover,.ed-bar button:focus-visible{border-color:var(--mint);outline:none} +.ed-body{background:rgba(4,8,7,.65);border:1px solid var(--line-strong);border-radius:11px; + min-height:180px;padding:12px 14px;outline:none;overflow-wrap:anywhere} +.ed-body:focus{border-color:var(--mint)} +.ed-body:empty::before{content:attr(data-ph);color:var(--muted)} +.ed-body img,.ed-body video,.ib-rich img,.ib-rich video{max-width:100%;border-radius:10px;margin:6px 0;display:block} +.ed-sep{width:1px;align-self:stretch;background:var(--line-strong);margin:2px 3px} +.ed-bar sub{font-size:9px} +textarea.ed-body{width:100%;min-height:180px;resize:vertical} +.ed-body a,.ib-rich a{color:var(--mint)} +.ed-body h3,.ib-rich h3,.ed-body h4,.ib-rich h4{margin:.5em 0 .3em} +.ed-media{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0 0} +/* ── earn sub-tabs (Watch ads | Inbox) ── */ +.subtabs{display:flex;gap:6px;margin:0 0 16px;border-bottom:1px solid var(--line);padding-bottom:0} +.subtab{background:none;border:0;border-bottom:2px solid transparent;color:var(--muted); + font:inherit;font-weight:700;font-size:14px;padding:8px 14px;cursor:pointer;margin-bottom:-1px; + display:inline-flex;align-items:center;gap:7px} +.subtab:hover{color:var(--ink)} +.subtab.on{color:var(--mint);border-bottom-color:var(--mint)} +.subtab .pill{position:static} +/* ── solo-ads inbox ── */ +.bo-menu .pill{margin-left:auto;background:var(--amber);color:#1a1206;font-size:11px;font-weight:800; + border-radius:999px;padding:1px 8px;line-height:1.5} +.ib-row{display:flex;gap:10px;align-items:baseline;padding:11px 6px;border-bottom:1px solid var(--line); + cursor:pointer;flex-wrap:wrap} +.ib-row:hover{background:rgba(67,232,195,.05)} +.ib-row .sub{font-weight:700;flex:1;min-width:160px;overflow-wrap:anywhere} +.ib-row.unread .sub{color:var(--mint)} +.ib-row .from,.ib-row .when{font-size:12px;color:var(--muted);white-space:nowrap} +.cta-need{box-shadow:0 0 0 2px var(--amber),0 0 16px rgba(255,178,56,.4)!important;animation:ctapulse 1.6s ease-in-out infinite} +@keyframes ctapulse{50%{box-shadow:0 0 0 2px var(--amber),0 0 24px rgba(255,178,56,.65)!important}} +@media (prefers-reduced-motion:reduce){.cta-need{animation:none}} +/* ── back-office accent family: green leads, cyan/violet/amber season the cards ── */ +.bo .stats .stat:nth-child(2) .n{color:var(--cyan)} +.bo .stats .stat:nth-child(2)::before{background:linear-gradient(90deg,transparent,var(--cyan),transparent)} +.bo .stats .stat:nth-child(3) .n{color:var(--violet)} +.bo .stats .stat:nth-child(3)::before{background:linear-gradient(90deg,transparent,var(--violet),transparent)} +.bo .stats .stat:nth-child(4) .n{color:var(--amber)} +.bo .stats .stat:nth-child(4)::before{background:linear-gradient(90deg,transparent,var(--amber),transparent)} +.bo .card h3::before{content:"";display:inline-block;width:9px;height:9px;border-radius:2.5px; + background:var(--mint);margin-right:10px;transform:rotate(45deg);vertical-align:1px} +#pane-line .card h3::before{background:var(--cyan)} +#pane-buy .card h3::before,#pane-campaigns .card h3::before{background:var(--amber)} +#pane-earn .card h3::before,#pane-earnings .card h3::before{background:var(--violet)} +#pane-promo .card h3::before{background:var(--cyan)} +#pane-wallet .card h3::before,#pane-profile .card h3::before{background:var(--mint)} +.bo .card{background:linear-gradient(165deg,rgba(24,36,31,.6),rgba(13,19,17,.66))} +#pane-line .qualbar,#nextCard{border-left:3px solid rgba(67,232,195,.4)} +.promo-block{background:rgba(4,8,7,.55);border:1px solid var(--line);border-radius:12px; + padding:14px 16px;margin:0 0 12px;font-size:13.5px;line-height:1.6;white-space:pre-wrap} +.promo-block .btn{margin-top:10px} +/* back-office polish: pane transitions, quick actions */ +@media(prefers-reduced-motion:no-preference){ + .pane:not([hidden]){animation:panein .25s ease} + @keyframes panein{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}} +} +.qa{display:flex;flex-direction:column;gap:8px} +.qa button{display:flex;align-items:center;gap:10px;background:rgba(67,232,195,.05);border:1px solid var(--line); + color:var(--ink);font:600 14px var(--disp);padding:11px 14px;border-radius:11px;cursor:pointer;text-align:left; + transition:border-color .15s ease,background .15s ease} +.qa button:hover{border-color:var(--mint);background:rgba(67,232,195,.1)} +/* member sub-navigation */ +.subnav{display:flex;gap:6px;flex-wrap:wrap;background:rgba(16,28,24,.7);border:1px solid var(--line); + border-radius:999px;padding:6px 8px;width:fit-content;margin:0 0 22px} +.subnav button{background:transparent;border:0;color:var(--muted);font:600 13.5px var(--disp); + padding:8px 16px;border-radius:999px;cursor:pointer} +.subnav button:hover{color:var(--ink)} +.subnav button.on{color:var(--mint-ink);background:var(--mint)} +/* member dashboard: qualification progress + roster */ +.qualbar{position:relative;height:12px;border-radius:999px;background:rgba(4,8,7,.7);border:1px solid var(--line-strong); + margin:26px 0 30px;max-width:520px} +.qualbar #qualFill{height:100%;border-radius:999px;background:linear-gradient(90deg,var(--mint),var(--mint-hi)); + box-shadow:0 0 18px rgba(67,232,195,.4);width:0;transition:width .6s ease} +.qb-mark{position:absolute;top:16px;transform:translateX(-50%);font-family:var(--mono);font-size:11px;color:var(--muted);white-space:nowrap} +.qb-mark.end{left:auto!important;right:0;transform:none} /* keeps the last label inside the card on mobile */ +#inviteLine,#pitchPreview{overflow-wrap:anywhere} +img{max-width:100%} +.roster{width:100%;border-collapse:collapse;font-size:13.5px} +.roster td{padding:8px 10px;border-bottom:1px solid var(--line)} +.roster td:first-child{font-family:var(--mono)} +.roster tr:last-child td{border-bottom:0} +/* chat widget */ +#iapChatBtn{position:fixed;right:20px;bottom:20px;z-index:40;width:56px;height:56px;border-radius:50%; + border:0;background:var(--mint);color:var(--mint-ink);font-size:24px;cursor:pointer; + box-shadow:0 8px 30px rgba(67,232,195,.4)} +#iapChatBtn:hover{transform:scale(1.06)} +#iapChatPanel{position:fixed;right:20px;bottom:88px;z-index:40;width:min(360px,calc(100vw - 40px)); + background:var(--panel-solid);border:1px solid var(--line-strong);border-radius:18px;overflow:hidden; + box-shadow:0 24px 70px rgba(0,0,0,.65);display:flex;flex-direction:column} +.ch-head{padding:14px 16px;border-bottom:1px solid var(--line);font-family:var(--disp);position:relative} +.ch-head .ch-sub{display:block;font-size:12px;color:var(--muted);font-family:"Segoe UI",system-ui,sans-serif} +#iapChatClose{position:absolute;right:10px;top:10px;background:transparent;border:0;color:var(--muted); + font-size:22px;cursor:pointer;line-height:1} +.ch-msgs{padding:14px;overflow-y:auto;max-height:340px;display:flex;flex-direction:column;gap:10px} +.ch-m{border-radius:12px;padding:9px 13px;font-size:13.5px;line-height:1.5;max-width:86%} +.ch-m.bot{background:rgba(67,232,195,.08);border:1px solid rgba(67,232,195,.2);align-self:flex-start} +.ch-m.me{background:rgba(130,148,196,.12);border:1px solid var(--line);align-self:flex-end} +.ch-m a{word-break:break-all} +.ch-input{display:flex;gap:8px;padding:11px;border-top:1px solid var(--line)} +.ch-input input{flex:1;min-width:0} +.ch-input button{background:var(--mint);color:var(--mint-ink);border:0;border-radius:10px;padding:0 16px; + font-weight:700;cursor:pointer;font-family:var(--disp)} +@media(prefers-reduced-motion:no-preference){ + .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 ── */ +.bo-chat{display:flex;align-items:center} +.bo-chat .pill{margin-left:auto;min-width:20px;height:20px;padding:0 6px;border-radius:10px;background:var(--mint); + color:var(--mint-ink);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:vertical;min-height:44px;max-height:45vh;line-height:1.45;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)} + +/* ── Overview line tree ── */ +.line-tree{margin-top:6px} +.lt-top{margin-left:26px} +.lt-you{display:inline-block;background:var(--mint);color:var(--mint-ink);font-weight:800;padding:6px 18px;border-radius:999px;font-size:13px} +.lt-stem{height:12px;border-left:2px solid var(--line-strong);width:0;margin:0 0 6px 30px} +.lt-row{display:flex;align-items:center;gap:10px;margin:7px 0} +.lt-cap{font:700 11px var(--mono);color:var(--muted);width:22px;flex:0 0 auto} +.lt-nodes{display:flex;gap:8px;flex-wrap:wrap} +.lt-node{background:var(--panel);border:1px solid var(--line-strong);border-radius:10px;padding:6px 12px;font-size:13px;font-weight:600;white-space:nowrap} +.lt-node.deep{color:var(--muted)} +.lt-node.qualified{border-color:#ffb238;color:#ffd15c;box-shadow:0 0 0 1px rgba(255,177,56,.35)} +.lt-node.open{border-style:dashed;color:var(--muted);background:transparent} +.lt-node .lt-n{display:inline-block;font-style:normal;font-size:10px;line-height:16px;min-width:16px;text-align:center;border-radius:8px;background:rgba(255,177,56,.18);color:#ffd15c;margin-left:6px;padding:0 4px;font-variant-numeric:tabular-nums} + +.sh-report{display:block;text-align:center;margin-top:10px;color:rgba(255,255,255,.45);font-size:12px} +.sh-report:hover{color:rgba(255,255,255,.8)} + +/* ── promo tools (2026-09-09) ── */ +.promo-strip{display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;padding:16px 20px} +.angle-list{display:flex;flex-direction:column;gap:8px} +.hp-field{position:absolute;left:-9999px;top:auto;width:1px;height:1px;opacity:0;overflow:hidden} +.hbars{display:flex;align-items:flex-end;gap:2px;height:20px;margin-top:6px;width:120px} +.hbars i{flex:1 1 0;background:var(--mint);opacity:.75;border-radius:1px 1px 0 0;min-width:2px} +.hbars i:hover{opacity:1} +.icon-check{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px} +.icon-check .ic-btn{font-size:26px;line-height:1;padding:10px 14px;border-radius:12px;border:1px solid var(--line-strong);background:var(--panel);cursor:pointer} +.icon-check .ic-btn:hover{border-color:var(--mint)} +.angle-row.pb-acc>summary{padding:12px 14px;font-size:14.5px} +.angle-row .angle-body{display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;padding:0 14px 14px} +.angle-row .angle-txt{flex:1 1 320px;min-width:0} +.angle-name{font-family:var(--disp);font-weight:700;font-size:15px} +.angle-hook{color:var(--mint);font-size:13px;font-weight:500} +.angle-tag{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);border:1px solid var(--line-strong);border-radius:999px;padding:1px 7px} +.angle-url{overflow-wrap:anywhere;margin-top:4px;display:block;color:var(--mint);text-decoration:none} +.angle-url:hover{text-decoration:underline} +.angle-btns{display:flex;gap:8px;flex-wrap:wrap} +.angle-btns .btn{margin-top:0} +.promo-pills{margin:4px 0 14px} +.promo-block .pb-head{display:flex;gap:8px;align-items:center;margin-bottom:8px;font-size:13.5px} +.promo-block .pb-net{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mint)} +.promo-block .pb-text{white-space:pre-wrap;font-size:14.5px;line-height:1.5} +.promo-block .pb-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px} +.promo-block .pb-actions .btn{margin-top:0} +.promo-bv{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:16px;padding:14px 16px;border:1px dashed var(--line-strong);border-radius:12px} +.promo-bv b{font-family:var(--disp)} +.obj-wrap{display:flex;flex-direction:column;gap:8px} +.obj{border:1px solid var(--line);border-radius:12px;background:rgba(4,8,7,.4)} +.obj summary{cursor:pointer;padding:12px 16px;font-family:var(--disp);font-weight:700;font-size:15px;list-style:none;display:flex;justify-content:space-between;gap:10px} +.obj summary::after{content:"+";color:var(--mint);font-weight:800} +.obj[open] summary::after{content:"–"} +.obj summary::-webkit-details-marker{display:none} +.obj summary::marker{content:""} +.obj summary{text-align:left} +.obj-body{padding:0 16px 14px;display:grid;gap:12px} +.obj .lab{display:inline-block;font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--mint);margin-bottom:4px} +.obj .lab-say{color:var(--amber)} +.obj-truth p{margin:0;font-size:14.5px;color:var(--ink)} +.obj-say{margin:0} + +.lin-row .earn{font-family:var(--mono);font-size:12px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums} +.lin-row .earn.on{color:var(--mint);font-weight:700} + +.wo-slot{border:1px solid var(--line);border-radius:12px;padding:12px 14px} +.wo-slot.locked{opacity:.6} +.wo-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px} +.wo-slot img{max-width:100%;border-radius:8px} + +/* getting-started stepper on the Overview (Jim could not find the wallet step, 2026-09-14) */ +.gs{border-color:rgba(67,232,195,.45)} +.gs-steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;margin:4px 0 12px} +.gs-step{display:flex;gap:10px;align-items:center;border:1px solid var(--line);border-radius:12px;padding:10px 12px;color:var(--muted);font-size:14px} +.gs-step .n{flex:0 0 26px;width:26px;height:26px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-weight:700;border:1px solid var(--line);font-size:13px} +.gs-step.done{color:var(--ink)} .gs-step.done .n{background:var(--mint);color:var(--mint-ink);border-color:var(--mint)} +.gs-step.now{color:var(--ink);border-color:var(--mint);background:rgba(67,232,195,.07)} .gs-step.now .n{border-color:var(--mint);color:var(--mint)} +.gs-now{display:flex;gap:14px;align-items:center;flex-wrap:wrap;border-top:1px solid var(--line);padding-top:12px} +.gs-now p{margin:0;flex:1;min-width:240px;max-width:60ch} .gs-now p b{display:block;font-family:var(--disp);font-size:16px;margin-bottom:2px} +@keyframes gsPulse{0%{box-shadow:0 0 0 0 rgba(67,232,195,.7)}70%{box-shadow:0 0 0 14px rgba(67,232,195,0)}100%{box-shadow:0 0 0 0 rgba(67,232,195,0)}} +.pulse{animation:gsPulse 1.2s ease-out 4} + +/* promo toolkit tiers */ +.tk-tiers{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px} +.tk-tier{border:1px solid var(--line);border-radius:12px;padding:12px 14px;font-size:13.5px;color:var(--muted)} +.tk-tier.reached{border-color:rgba(67,232,195,.5);color:var(--ink)} .tk-tier.current{background:rgba(67,232,195,.07)} +.tk-tier b{display:block;font-family:var(--disp);font-size:15px;color:var(--ink)} .tk-tier .need{font-size:12px;margin:2px 0 8px;color:var(--muted)} +.tk-tier ul{margin:0;padding-left:16px} .tk-tier li{margin:3px 0} .tk-tier li.soon{opacity:.65} .tk-tier li.soon::after{content:' (coming)';font-size:11px;color:var(--muted)} +.tk-tier .lock{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#ffd15c} +.tk-hist{border-top:1px solid var(--line);padding:8px 0;font-size:13px} .tk-hist .muted{font-size:12px} +.tk-sec{margin-top:18px;padding-top:14px;border-top:1px solid var(--line)} .tk-grid{display:flex;gap:8px;flex-wrap:wrap} +.tk-vids{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px} .tk-vid{border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:13px} .tk-vid b{display:block;font-weight:600;margin-bottom:4px} .tk-vid .st{font-size:12px;color:var(--muted)} +.tk-bar{height:8px;border-radius:99px;background:rgba(255,255,255,.08);overflow:hidden;margin:6px 0 4px} .tk-bar i{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,#1fb894,#43e8c3);transition:width .6s ease;min-width:4px} +.tk-table{width:100%;border-collapse:collapse;font-size:13px} .tk-table th,.tk-table td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)} .tk-table th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)} .tk-table td.n{font-variant-numeric:tabular-nums;text-align:right} .tk-table th.n{text-align:right} .tk-table tr.best td{color:var(--mint)} +.tk-team{border:1px solid var(--line);border-radius:10px;padding:8px 12px;margin:6px 0;font-size:13px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;justify-content:space-between} .tk-team .who b{font-weight:600} .tk-team .who span{color:var(--muted);font-size:12px} .tk-team.stalled{border-color:rgba(255,209,92,.45)} +.tk-nudge{width:100%;margin:8px 0 0;padding:8px 12px;border:1px dashed var(--line);border-radius:10px;font-size:13px} +/* click sources stack one per line so the Clicks column stays narrow (Marty, 2026-09-15) */ +.clk-src span{display:block;white-space:nowrap} .camp-table td{white-space:normal} .camp-table td:first-child{max-width:260px} +/* campaigns list on phones and tablets: each row becomes a card, buttons at the bottom, no sideways scroll (Jim, 2026-09-15) */ +@media (max-width:900px){ + .tablewrap:has(.camp-table){overflow:visible} + .camp-table{display:block;width:100%;border-collapse:separate} + .camp-table thead{display:none} + .camp-table tbody{display:block} + .camp-table tr{display:block;border:1px solid var(--line);border-radius:12px;padding:10px 12px 12px;margin:0 0 10px;background:var(--panel)} + .camp-table td{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:5px 0;border:0;text-align:left;max-width:100%;overflow:hidden} + .camp-table td.num{text-align:right;font-variant-numeric:tabular-nums} + .camp-table td::before{content:attr(data-l);color:var(--muted);font-size:11px;letter-spacing:.06em;text-transform:uppercase;flex:0 0 auto} + .camp-table td:first-child{display:block;padding:0 0 6px;font-size:16px} .camp-table td:first-child::before{display:none} + .camp-table td:first-child > *{max-width:100%} + .camp-table td.act{display:flex;flex-wrap:wrap;justify-content:flex-start;gap:8px;margin-top:8px;padding-top:10px;border-top:1px solid var(--line)} .camp-table td.act::before{display:none} + .camp-table td.act .btn{flex:1 1 auto;text-align:center} +} + +/* Pipeline board (Marty, 2026-09-15): columns scroll sideways inside their own box, never the page */ +.pipe-board{display:flex;gap:10px;overflow-x:auto;padding:4px 2px 10px;scroll-snap-type:x proximity} +.pipe-col{flex:0 0 220px;scroll-snap-align:start;background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:12px;padding:10px;min-height:120px} +.pipe-col h4{margin:0 0 2px;font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--mint);display:flex;justify-content:space-between;gap:8px} +.pipe-col h4 span{color:var(--muted);font-variant-numeric:tabular-nums} +.pipe-col .hint{font-size:11.5px;color:var(--muted);margin:0 0 8px;line-height:1.35} +.pipe-card{background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:10px;padding:8px 10px;margin:0 0 8px;cursor:pointer;transition:border-color .15s} +.pipe-card:hover,.pipe-card:focus-visible{border-color:var(--mint);outline:none} +.pipe-card.on{border-color:var(--gold,#e6c15a)} +.pipe-card .nm{font-weight:700;font-size:14px;display:flex;justify-content:space-between;gap:6px;align-items:baseline} +.pipe-card .nm small{font-weight:400;font-size:11px;color:var(--muted);white-space:nowrap} +.pipe-card .sub{font-size:12px;color:var(--muted);margin-top:3px;line-height:1.35} +.pipe-card .note{font-size:12px;color:var(--ink);margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.pipe-card .chip{margin-top:5px;margin-right:4px} +.pipe-due{display:grid;gap:6px} +.pipe-due .pipe-card{margin:0} +@media (max-width:640px){.pipe-form{grid-template-columns:1fr !important}.pipe-col{flex-basis:200px}} diff --git a/public/assets/tx.js b/public/assets/tx.js new file mode 100644 index 0000000..31b4fea --- /dev/null +++ b/public/assets/tx.js @@ -0,0 +1,42 @@ +// Transaction viewer: renders one tx from /api/tx/ (the server-side RPC +// relay, so the browser never talks to the chain directly and CSP stays 'self'). +// This is the "verify it yourself" page for chains without a public explorer; +// when the config carries an explorer URL, feed links point there instead. +(async function () { + await IAP.renderNav('ledger'); + const c = await IAP.getConfig(); + const hash = location.pathname.split('/').pop(); + IAP.$('txHash').textContent = hash; + IAP.$('txChain').textContent = 'on ' + (c.chainName || 'the settlement chain'); + let r = null; + try { r = await (await fetch('/api/tx/' + hash)).json(); } catch (e) {} + const stat = IAP.$('txStatus'); + if (!r || !r.found) { + stat.textContent = 'not found'; + stat.className = 'badge amber'; + IAP.$('txHint').hidden = false; + return; + } + const ok = r.status === '0x1'; + stat.textContent = ok ? '✓ confirmed' : '✗ reverted'; + stat.className = 'badge' + (ok ? '' : ' amber'); + const hx = v => { try { return parseInt(v, 16); } catch (e) { return 0; } }; + const rows = [ + ['Block', '#' + hx(r.blockNumber).toLocaleString()], + ['Time', r.ts ? new Date(hx(r.ts) * 1000).toLocaleString() : 'pending'], + ['From', r.from || ''], + ['To (contract)', r.to || ''], + ['Value sent', IAP.fmtPol(r.valueWei || '0') + ' POL'], + ['Gas used', hx(r.gasUsed).toLocaleString()] + ]; + const tb = IAP.$('txTable'); + tb.hidden = false; + tb.querySelector('tbody').innerHTML = rows.map(x => + '' + x[0] + '' + + '' + String(x[1]).replace(/[&<>]/g, '') + '').join(''); + if (r.events && r.events.length) { + IAP.$('evCard').hidden = false; + const feed = IAP.$('evFeed'); + for (const ev of r.events) feed.appendChild(IAP.feedRow(ev, c)); + } +})(); diff --git a/public/assets/view.js b/public/assets/view.js new file mode 100644 index 0000000..ceaa90b --- /dev/null +++ b/public/assets/view.js @@ -0,0 +1,122 @@ +// Full-screen ad viewer: the advertiser URL fills the tab, a countdown runs in +// the top bar (paused whenever this tab loses focus), and once the dwell is +// done the server hands out a human check. Solve it and the view credits. +// The dwell floor is enforced on the SERVER clock; this UI cannot cheat it. +(() => { + const $ = id => document.getElementById(id); + const token = location.pathname.split('/').pop(); + const framed = window.self !== window.top; // shown inside the dashboard's in-page ad overlay + let left = 5, timer = null, credited = false, asking = false; + const setMsg = t => { $('vMsg').textContent = t; }; + async function j(url, body) { + const r = await fetch(url, body + ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } + : undefined); + return r.json(); + } + function tick() { + if (credited || asking) return; + // In the in-page overlay the iframe often doesn't hold focus even though it's + // fully on screen, so gate on tab visibility only when framed. + if (document.visibilityState !== 'visible' || (!framed && !document.hasFocus())) { + $('vTimer').classList.add('paused'); + $('vTimer').textContent = 'paused'; + setMsg(framed ? 'Keep this ad on screen to finish the countdown.' : 'Come back to this tab to keep the countdown moving.'); + return; + } + $('vTimer').classList.remove('paused'); + left = Math.max(0, left - 0.25); + $('vTimer').textContent = Math.ceil(left) + 's left'; + if (left <= 0) { clearInterval(timer); askChallenge(); } + } + async function askChallenge() { + asking = true; + $('vTimer').textContent = 'check'; + $('vTimer').classList.add('done'); + setMsg('One quick check to count the view:'); + let c = await j('/api/my/viewchallenge?token=' + token); + if (c.early) { // server clock says not quite yet: wait it out and re-ask + await new Promise(r => setTimeout(r, (c.wait || 1) * 1000 + 300)); + c = await j('/api/my/viewchallenge?token=' + token); + } + if (c.error) return fail(c.error); + renderChallenge(c); + } + function renderChallenge(c) { + $('vPrompt').textContent = 'Click the ' + c.prompt + ':'; + const w = $('vOpts'); + w.innerHTML = ''; + c.options.forEach((em, i) => { + const b = document.createElement('button'); + b.type = 'button'; + b.textContent = em; + b.addEventListener('click', () => answer(i)); + w.appendChild(b); + }); + $('vCheck').classList.add('on'); + } + async function answer(i) { + const r = await j('/api/my/adview', { token, answer: i }); + if (r.error) { + if (r.retry) { + setMsg('Not that one — try again.'); + const c = await j('/api/my/viewchallenge?token=' + token); + if (!c.error) return renderChallenge(c); + return fail(c.error); + } + return fail(r.error); + } + credited = true; + $('vCheck').classList.remove('on'); + $('vTimer').textContent = '✓ credited'; + const st = r.status || r; + setMsg('View ' + st.views + ' of ' + st.target + ' counted for today.' + + (st.views >= st.target && !st.claimed ? ' Head back and claim your credits.' : '')); + $('vDone').classList.add('on'); + try { localStorage.setItem('iap-view-done', String(Date.now())); } catch (e) {} + try { if (framed) window.parent.postMessage({ t: 'iap-view-done' }, location.origin); } catch (e) {} + } + function fail(msg) { + credited = true; // stop the loop; this view is over either way + if (timer) clearInterval(timer); + $('vCheck').classList.remove('on'); + $('vTimer').textContent = '—'; + setMsg(msg); + $('vDone').classList.add('on'); + } + $('vClose').addEventListener('click', () => { + window.close(); + // window.close() is blocked in mobile and in-app (wallet dApp) browsers. If + // the tab is still here a moment later, take them back to the dashboard so + // they are never stuck on a tab they can't close. + setTimeout(() => { + if (!window.closed) { setMsg('Taking you back to your dashboard…'); location.href = '/my#earn'; } + }, 400); + }); + // When shown inside the dashboard's in-page overlay there is no tab to close: + // hide "Close tab" and turn "Back to dashboard" into a close-the-overlay signal. + if (framed) { + const cb = $('vClose'); if (cb) cb.style.display = 'none'; + const back = document.querySelector('#vDone a[href="/my#earn"]'); + if (back) back.addEventListener('click', e => { e.preventDefault(); try { window.parent.postMessage({ t: 'iap-view-close' }, location.origin); } catch (x) {} }); + } + (async () => { + const info = await j('/api/my/viewinfo?token=' + token); + if (info.error) { + $('vFrame').remove(); + const d = document.createElement('div'); + d.className = 'vfail'; + d.textContent = info.error; + document.querySelector('.vw').appendChild(d); + $('vDone').classList.add('on'); + $('vTimer').textContent = '—'; + setMsg(''); + return; + } + left = info.dwell || 5; + $('vFrame').src = info.targetUrl; + setMsg('Watching: ' + (info.adName || 'member ad') + ' — stay on this tab.'); + $('vTimer').textContent = left + 's left'; + timer = setInterval(tick, 250); + })(); +})(); diff --git a/public/assets/wall.js b/public/assets/wall.js new file mode 100644 index 0000000..d02cd4a --- /dev/null +++ b/public/assets/wall.js @@ -0,0 +1,104 @@ +// Public banner wall: a member's line banner plus their upline ladder, with +// their join link. The viral surface: members send traffic here, every visit +// puts eyes on the whole line. +(async function () { + await IAP.renderNav(''); + const name = location.pathname.split('/').pop(); + let w = null; + try { w = await (await fetch('/api/wall/' + encodeURIComponent(name))).json(); } catch (e) {} + const title = IAP.$('wallTitle'); + const hostEl = IAP.$('wallHost'); + if (!w || w.error) { + title.textContent = 'No wall under that name.'; + IAP.$('wallJoin').href = '/my'; + return; + } + const safeName = String(w.name).replace(/[&<>]/g, ''); + title.innerHTML = safeName; + if (hostEl) hostEl.innerHTML = safeName; + IAP.$('bioHead').hidden = false; + if (w.avatarUrl) { const av = IAP.$('bioAvatar'); av.src = w.avatarUrl; av.hidden = false; } + if (w.badge) { const bb = IAP.$('bioBadge'); if (bb) { bb.src = w.badge.img; bb.title = w.badge.label + ' badge'; bb.hidden = false; } } + IAP.$('bioText').textContent = w.bio || 'Building a team on LinkSpin — join through this page and you\'re in my line.'; + if (w.qrUrl) IAP.$('bioQr').src = w.qrUrl; + // social links + const SOC = { facebook: 'Facebook', twitter: 'X', youtube: 'YouTube', instagram: 'Instagram', tiktok: 'TikTok', telegram: 'Telegram', linkedin: 'LinkedIn', website: 'Website' }; + // small line icons, one per platform (currentColor so they follow the pill color) + const svg = inner => ''; + const ICON = { + facebook: svg(''), + twitter: svg(''), + youtube: svg(''), + instagram: svg(''), + tiktok: svg(''), + telegram: svg(''), + linkedin: svg(''), + website: svg('') + }; + const sc = IAP.$('bioSocials'); + if (sc && w.socials && typeof w.socials === 'object') { + const links = Object.keys(SOC).filter(k => w.socials[k]).map(k => + '' + (ICON[k] || '') + '' + SOC[k] + ''); + sc.innerHTML = links.join(''); + } + // intro video under the bio: YouTube / Vimeo embed, or a direct file + const bv = IAP.$('bioVideo'); + const vurl = w.socials && typeof w.socials === 'object' ? String(w.socials.video || '') : ''; + if (bv && vurl) { + let yt = /(?:youtube\.com\/(?:watch\?(?:.*&)?v=|shorts\/|embed\/)|youtu\.be\/)([A-Za-z0-9_-]{6,})/.exec(vurl); + let vm = /vimeo\.com\/(?:video\/)?(\d+)/.exec(vurl); + let inner = ''; + if (yt) inner = ''; + else if (vm) inner = ''; + else if (/\.(mp4|webm)(\?|$)/i.test(vurl)) inner = ''; + if (inner) { bv.innerHTML = '

    A word from ' + String(w.name).replace(/[&<>]/g, '') + '

    ' + inner; bv.hidden = false; } + } + IAP.$('wallCtaHead').textContent = 'Join ' + w.name + '’s line'; + // per-wall viewed-position memory (survives revisits; anonymous-friendly) + const VKEY = 'iap-wall-' + name; + let viewed = {}; + try { viewed = JSON.parse(localStorage.getItem(VKEY) || '{}'); } catch (e) {} + const DWELL = 10; + const grid = IAP.$('wallGrid'); + IAP.adSlot('text', 'adSlotWallText'); IAP.adSlot('banner', 'adSlotWallBanner'); + grid.innerHTML = ''; + // join is gated: you must view every ad on the wall before you can join + const joinBtn = IAP.$('wallJoin'), gate = IAP.$('wallGate'); + function updateGate() { + const viewable = w.ladder.filter(m => m.targetUrl).length; + const seen = w.ladder.filter((m, i) => m.targetUrl && viewed[i]).length; + if (viewable > 0 && seen < viewable) { + joinBtn.classList.add('disabled'); joinBtn.removeAttribute('href'); + if (gate) { gate.hidden = false; gate.textContent = 'View all ' + viewable + ' ads above to unlock joining (' + seen + '/' + viewable + ' viewed).'; } + } else { joinBtn.href = w.joinUrl; joinBtn.classList.remove('disabled'); if (gate) gate.hidden = true; } + } + w.ladder.forEach((m, i) => { + const d = document.createElement('div'); + d.className = 'wall-card'; + const safe = String(m.name || 'member').replace(/[&<>]/g, ''); + const creative = m.bannerUrl + ? '' + safe + ' banner' + : '' + safe + '
    ' + (m.targetUrl ? 'visit their site' : 'banner slot open') + ''; + d.innerHTML = '
    Position ' + (i + 1) + (m.own ? ' · this wall' : m.admin ? ' · LinkSpin' : ' · their line') + '
    ' + + '
    ' + creative + '
    ' + + '
    ' + + '
    ' + safe + '
    '; + const act = d.querySelector('.wc-action'); + const done = () => { act.innerHTML = '✓ viewed'; }; + if (!m.targetUrl) { act.innerHTML = 'no ad yet'; } + else if (viewed[i]) { done(); } + else { + const btn = document.createElement('button'); + btn.className = 'btn small'; btn.textContent = 'View this ad'; + btn.addEventListener('click', () => { + window.open(m.targetUrl, '_blank'); // real visit to the advertiser (no countdown) + viewed[i] = 1; + try { localStorage.setItem(VKEY, JSON.stringify(viewed)); } catch (e) {} + done(); updateGate(); + }); + act.appendChild(btn); + } + grid.appendChild(d); + }); + updateGate(); +})(); diff --git a/public/assets/wallet.js b/public/assets/wallet.js new file mode 100644 index 0000000..64b874a --- /dev/null +++ b/public/assets/wallet.js @@ -0,0 +1,284 @@ +// Wallet plumbing via Reown AppKit — the universal connector every wallet is +// built for (all wallets, QR + mobile deep-links, working icons). AppKit is +// lazy-loaded from the CDN on first use; once connected we drive the raw +// EIP-1193 provider for chain switch, SIWE sign-in, and contract transactions. +window.IAPWallet = (function () { + const SEL_BUY = '0xfd095e97'; // buy(uint32,uint32) + const SEL_ACTIVATE = '0x1a93ec95'; // activate(uint32) + const pad = v => BigInt(v).toString(16).padStart(64, '0'); + // Normalize a chainId to a decimal number. eth_chainId is meant to return a + // hex string, but some wallets return a number or a decimal string — compare + // numerically so a wallet's shape never crashes the flow. + const chainNum = v => { + if (v == null) return NaN; + if (typeof v === 'number') return v; + const s = String(v).trim(); + return /^0x/i.test(s) ? parseInt(s, 16) : parseInt(s, 10); + }; + const APPKIT_URL = 'https://cdn.jsdelivr.net/npm/@reown/appkit-cdn@1.8.23/dist/appkit.js'; + + let modal = null, akPromise = null, provider = null; + + async function initAppKit(c) { + if (modal) return modal; + if (akPromise) return akPromise; + akPromise = (async () => { + const mod = await import(APPKIT_URL); + const { createAppKit, WagmiAdapter, networks } = mod; + const netMap = { 80002: networks.polygonAmoy, 137: networks.polygon }; + const base = netMap[Number(c.chainId)] || networks.polygonAmoy; + // Override the chain's RPC with our clean public endpoint. AppKit's built-in + // networks advertise the WalletConnect RPC proxy + // (rpc.walletconnect.org/v1/?chainId=…&projectId=…) as the chain RPC, and + // wallets reject that query-string URL as "Invalid URL" when adding/switching + // the network — the cause of Trust's "Invalid URL", MetaMask's switch loop, + // and the failed mobile buy (the chain switch never completed). + const rpc = String(c.rpc || '').trim(); + const net = rpc ? Object.assign({}, base, { rpcUrls: { default: { http: [rpc] }, public: { http: [rpc] } } }) : base; + // Accept the wallet's usual networks too, so AppKit doesn't trap the user in + // its own "Switch Network" modal — that modal loops on a testnet the wallet + // can't auto-add. We switch to `net` ourselves (wallet_addEthereumChain adds + // + switches in one step) and sendTx hard-guards the chain before signing. + const allNets = [net]; + for (const k of ['polygon', 'mainnet']) { + try { const n = networks[k]; if (n && n.id !== net.id) allNets.push(n); } catch (e) {} + } + const projectId = String(c.walletConnectProjectId || '').trim(); + const wagmiAdapter = new WagmiAdapter({ networks: allNets, projectId }); + modal = createAppKit({ + adapters: [wagmiAdapter], networks: allNets, projectId, defaultNetwork: net, + metadata: { name: c.siteName || 'LinkSpin', description: c.tagline || 'Advertise and earn, paid on-chain.', + url: location.origin, icons: [location.origin + '/logo-icon.png'] }, + features: { analytics: false, email: false, socials: [] }, + // Picker order: wallets without Trust's balance-proportion block go first. + // Trust Wallet is NOT excluded; it just drops out of the featured row into + // "All wallets" (Marty, 2026-09-09: move Trust to the bottom, not off). + featuredWalletIds: [ + 'c57ca95b47569778a828d19178114f4db188b89b763c899ba0be274e97267d96', // MetaMask + 'a797aa35c0fadbfc1a53e7f675162ed5226968b44a19ee3d24385c64d1d3c393', // Phantom + '0b415a746fb9ee99cce155c2ceca0c6f6061b1dbca2d722b3ba16381d0562150', // SafePal + 'fd20dc426fb37566d803205b19bbc1d4096b248ac04548e3cfb6b3a38bd033aa' // Coinbase Wallet + ] + }); + return modal; + })(); + return akPromise; + } + + function currentAddress() { try { return (modal && modal.getAddress && modal.getAddress()) || null; } catch (e) { return null; } } + + function waitForConnection(timeoutMs) { + if (currentAddress()) return Promise.resolve(currentAddress()); + return new Promise((resolve, reject) => { + let done = false, unsub = null; + const finish = (addr, err) => { if (done) return; done = true; try { unsub && unsub(); } catch (e) {} err ? reject(err) : resolve(addr); }; + try { unsub = modal.subscribeAccount(acc => { if (acc && acc.isConnected && acc.address) finish(acc.address); }); } catch (e) {} + const t0 = Date.now(); + (function poll() { + if (done) return; + const a = currentAddress(); + if (a) return finish(a); + if (Date.now() - t0 > (timeoutMs || 180000)) return finish(null, new Error('Wallet connection timed out. Tap Connect and try again.')); + setTimeout(poll, 400); + })(); + }); + } + + async function resolveProvider() { + for (let i = 0; i < 20; i++) { + try { const p = modal.getWalletProvider ? await Promise.resolve(modal.getWalletProvider()) : null; if (p && p.request) return p; } catch (e) {} + await new Promise(r => setTimeout(r, 300)); + } + throw new Error('Could not reach your wallet. Try connecting again.'); + } + + function eth() { if (!provider) throw new Error('Connect your wallet first.'); return provider; } + const isInjected = () => !!(provider && window.ethereum && (provider === window.ethereum || provider.isMetaMask)); + // the account the wallet will actually sign with: for an injected wallet (MetaMask + // extension) that is its active account, which can differ from AppKit's cached one + async function activeAddress(fallback) { + if (isInjected()) { try { const a = await provider.request({ method: 'eth_accounts' }); if (a && a[0]) return a[0]; } catch (e) {} } + return fallback || currentAddress(); + } + // Force the wallet's own account picker. Injected wallets stay connected to the + // site, so a plain disconnect/reconnect never shows one: asking for permissions + // again makes MetaMask open its account-selection prompt, and whatever the user + // ticks becomes the active account. WalletConnect wallets fall back to a fresh + // session (the picker + the wallet app's own account choice). + async function pickAccount() { + const c = await IAP.getConfig(); + await initAppKit(c); + const inj = window.ethereum; + if (inj && inj.request) { + try { + await inj.request({ method: 'wallet_requestPermissions', params: [{ eth_accounts: {} }] }); + const accs = await inj.request({ method: 'eth_accounts' }); + if (accs && accs[0]) { provider = inj; await ensureChain(c).catch(() => {}); return accs[0]; } + } catch (e) { + if (e && (e.code === 4001 || /reject|denied/i.test(String(e.message || '')))) throw new Error('You closed the account picker. Pick the account you want and try again.'); + } + } + return freshConnect(c); + } + + // A WalletConnect session can die underneath AppKit's cached "connected" + // state: the wallet app rejects or kills it (Trust does this after its own + // security stop), the phone sleeps, the relay drops. AppKit still reports an + // address, so the next request fails with a "disconnected" style error. + // Detect that, wipe the stale session, and re-open the picker for a fresh one. + const DEAD_RE = /disconnect|not connected|no matching key|session (topic|expired|deleted|not found)|call connect|please call connect|missing or invalid|relay/i; + const isDead = e => DEAD_RE.test(String((e && e.message) || e || '')); + async function freshConnect(c) { + try { IAP.status('Your wallet session dropped. Reconnect in the picker…'); } catch (e) {} + await disconnect(); + try { await modal.open(); } catch (e) {} + const addr = await waitForConnection(180000); + try { if (modal && modal.close) await modal.close(); } catch (e) {} + provider = await resolveProvider(); + await ensureChain(c).catch(() => {}); + return addr; + } + + async function connect() { + const c = await IAP.getConfig(); + await initAppKit(c); + let addr = currentAddress(); + if (!addr) { + // give AppKit a moment to rehydrate an existing session before popping the + // picker — otherwise an already-connected wallet still gets the modal + for (let i = 0; i < 8 && !addr; i++) { await new Promise(r => setTimeout(r, 150)); addr = currentAddress(); } + } + if (!addr) { try { await modal.open(); } catch (e) {} addr = await waitForConnection(180000); } + try { if (modal && modal.close) await modal.close(); } catch (e) {} // dismiss the picker once we're connected + provider = await resolveProvider(); + // probe the session: a dead WalletConnect session answers with a disconnect error + try { await provider.request({ method: 'eth_chainId' }); } + catch (e) { if (isDead(e)) return freshConnect(c); } + await ensureChain(c).catch(() => {}); // AppKit already connects on the right network; switch is best-effort + return addr; + } + + async function ensureChain(c) { + const want = '0x' + Number(c.chainId).toString(16); + let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) { return; } + if (chainNum(cur) === Number(c.chainId)) return; + try { + await eth().request({ method: 'wallet_switchEthereumChain', params: [{ chainId: want }] }); + } catch (e) { + // any failure (not just 4902): try to add the chain — wallet_addEthereumChain + // adds AND switches in one step, which is what unblocks wallets that can't + // otherwise reach a chain they don't already have (e.g. a testnet). + const addParams = { chainId: want, chainName: c.chainName, nativeCurrency: { name: 'POL', symbol: 'POL', decimals: 18 }, rpcUrls: [c.rpc] }; + if (c.explorer && /^https?:\/\//i.test(c.explorer)) addParams.blockExplorerUrls = [c.explorer]; + try { await eth().request({ method: 'wallet_addEthereumChain', params: [addParams] }); } catch (e2) {} + } + } + + // SIWE: challenge -> personal_sign -> verify (server sets the session cookie) + async function signIn(opts) { + const addr = (opts && opts.pick) ? await pickAccount() : await activeAddress(await connect()); + const ch = await (await fetch('/api/auth/challenge', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr }) })).json(); + if (ch.error) throw new Error(ch.error); + // hex-encode the message (Trust and others require hex for personal_sign) + const hexMsg = '0x' + Array.from(new TextEncoder().encode(ch.message)).map(b => b.toString(16).padStart(2, '0')).join(''); + const sig = await eth().request({ method: 'personal_sign', params: [hexMsg, addr] }); + const r = await (await fetch('/api/auth/verify', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr, signature: sig, asPosition: !!(opts && opts.asPosition) }) })).json(); + if (r.error) throw new Error(r.error); + return r; + } + + async function sendTx(data, valueWei) { + const c = await IAP.getConfig(); + const addr = await connect(); + // Hard chain guard: connect()'s switch is best-effort and some wallets (or + // AppKit's own modal) don't complete it. Never sign on the wrong chain — + // a value tx to a contract that doesn't exist on that chain would look like + // it "succeeded" while doing nothing. + const wantNum = Number(c.chainId); + let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {} + if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum) { + await ensureChain(c); + try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {} + if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum) + throw new Error('Your wallet is on the wrong network. Switch it to ' + (c.chainName || 'the correct network') + ', then try again.'); + } + const tx = { from: await activeAddress(addr), to: c.contract, data }; + if (valueWei) tx.value = '0x' + BigInt(valueWei).toString(16); + // Amoy's Bor nodes enforce a ~25 gwei minimum priority fee that MetaMask's + // own estimate misses ("gas tip below minimum"). Pull the network's correct + // fees from the server and set them so the tx clears the floor. + try { + const g = await (await fetch('/api/gas')).json(); + if (g && g.maxPriorityFeePerGas && g.maxFeePerGas) { + tx.maxPriorityFeePerGas = g.maxPriorityFeePerGas; + tx.maxFeePerGas = g.maxFeePerGas; + } + } catch (e) {} + try { + return await eth().request({ method: 'eth_sendTransaction', params: [tx] }); + } catch (e) { + if (!isDead(e)) throw e; + // session died between connect and send: reconnect once and resend + tx.from = await freshConnect(c); + return eth().request({ method: 'eth_sendTransaction', params: [tx] }); + } + } + + // pay it forward: send POL straight from the sponsor's wallet to a downline member's + // linked address. A native transfer, no contract, no site custody: the wallet app + // shows the prefilled recipient and amount and the sponsor confirms there. + async function sendPol(toAddress, valueWei) { + if (!/^0x[0-9a-fA-F]{40}$/.test(String(toAddress || ''))) throw new Error('That member has no wallet address on file yet.'); + const c = await IAP.getConfig(); + const addr = await connect(); + const wantNum = chainNum(c.chainId); + let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {} + if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum) { await ensureChain(c); } + const tx = { from: await activeAddress(addr), to: toAddress, value: '0x' + BigInt(valueWei).toString(16) }; + try { const g = await (await fetch('/api/gas')).json(); if (g && g.maxPriorityFeePerGas && g.maxFeePerGas) { tx.maxPriorityFeePerGas = g.maxPriorityFeePerGas; tx.maxFeePerGas = g.maxFeePerGas; } } catch (e) {} + try { return await eth().request({ method: 'eth_sendTransaction', params: [tx] }); } + catch (e) { if (!isDead(e)) throw e; tx.from = await freshConnect(c); return eth().request({ method: 'eth_sendTransaction', params: [tx] }); } + } + + async function waitTx(hash) { + for (let i = 0; i < 90; i++) { + try { + const r = await (await fetch('/api/tx/' + hash)).json(); + if (r.found) return { status: r.status, blockNumber: r.blockNumber }; + } catch (e) { /* transient fetch failure (e.g. mobile app-switch) — keep polling */ } + await new Promise(res => setTimeout(res, 2500)); + } + throw new Error('Timed out waiting for the transaction. Check the explorer.'); + } + + async function buy(productId, sponsorId, costWei) { + const value = BigInt(costWei) + BigInt(costWei) / 50n; // 2% oracle-drift pad; contract refunds excess + const data = SEL_BUY + pad(productId) + pad(sponsorId || 0); + const hash = await sendTx(data, value); + return { hash, receipt: await waitTx(hash) }; + } + async function activate(sponsorId) { + const data = SEL_ACTIVATE + pad(sponsorId || 0); + const hash = await sendTx(data, null); + return { hash, receipt: await waitTx(hash) }; + } + + async function disconnect() { + // AppKit's disconnect can hang on the WalletConnect relay (esp. mobile) — + // never block on it, so the UI can't get stuck "disconnecting". + try { if (modal && modal.disconnect) await Promise.race([modal.disconnect(), new Promise(r => setTimeout(r, 1200))]); } catch (e) {} + provider = null; + try { Object.keys(localStorage).forEach(k => { if (/wc@2|walletconnect|w3m|wcm|reown|wagmi|appkit/i.test(k)) localStorage.removeItem(k); }); } catch (e) {} + } + + // native balance of the connected wallet (pre-flight check before a buy) + async function balance(addr) { + await connect(); + const h = await eth().request({ method: 'eth_getBalance', params: [addr || currentAddress(), 'latest'] }); + return BigInt(h); + } + function walletName() { try { const w = modal && modal.getWalletInfo && modal.getWalletInfo(); return (w && w.name) || ''; } catch (e) { return ''; } } + return { connect, signIn, buy, activate, sendPol, waitTx, disconnect, balance, address: currentAddress, activeAddress, pickAccount, walletName }; +})(); diff --git a/public/assets/wallets.js b/public/assets/wallets.js new file mode 100644 index 0000000..c213999 --- /dev/null +++ b/public/assets/wallets.js @@ -0,0 +1,23 @@ +// Wallets + buying POL guide: members only. +(async function () { + try { await IAP.renderNav('training'); } catch (e) {} + let me = null; + try { me = await (await fetch('/api/me')).json(); } catch (e) {} + const signedIn = !!(me && me.signedIn && me.email); + document.getElementById('gate').style.display = signedIn ? 'none' : 'block'; + document.getElementById('body').style.display = signedIn ? 'block' : 'none'; + if (!signedIn) return; + // MoonPay: same signed link the Buy pane uses, prefilled with this member's wallet when one is linked + const mb = document.getElementById('wlMoonpay'), note = document.getElementById('wlMoonpayNote'); + if (note && !me.address) note.textContent = 'Link your wallet first (Wallet tab) and MoonPay opens with your address already filled in. Without it you paste the address yourself.'; + if (mb) mb.addEventListener('click', async () => { + let pol = 30; + try { const { products } = await (await fetch('/api/catalog')).json(); const p20 = (products || []).find(p => p.priceCents === 2000); if (p20 && p20.costWei) pol = Math.max(30, Math.ceil(Number(p20.costWei) / 1e18) + 3); } catch (e) {} + try { + const r = await (await fetch('/api/moonpay-url?pol=' + pol + (me.address ? '&address=' + encodeURIComponent(me.address) : ''))).json(); + if (!r.url) throw new Error('no url'); + window.open(r.url, '_blank', 'noopener'); + IAP.status(r.signed ? 'MoonPay opened with your wallet address pre-filled. Choose the amount, pay, and the POL lands in your wallet.' : 'MoonPay opened. Choose POL on the Polygon network and paste your own wallet address as the destination.', 'ok'); + } catch (e) { IAP.status('Could not open MoonPay. Try again in a minute.', 'bad'); } + }); +})(); diff --git a/public/badges/badge-circuit.jpg b/public/badges/badge-circuit.jpg new file mode 100644 index 0000000..bb414fb Binary files /dev/null and b/public/badges/badge-circuit.jpg differ diff --git a/public/badges/badge-nexus.jpg b/public/badges/badge-nexus.jpg new file mode 100644 index 0000000..9de3967 Binary files /dev/null and b/public/badges/badge-nexus.jpg differ diff --git a/public/badges/badge-spark.jpg b/public/badges/badge-spark.jpg new file mode 100644 index 0000000..fe3b15c Binary files /dev/null and b/public/badges/badge-spark.jpg differ diff --git a/public/badges/badge-surge.jpg b/public/badges/badge-surge.jpg new file mode 100644 index 0000000..14f3861 Binary files /dev/null and b/public/badges/badge-surge.jpg differ diff --git a/public/banners/iap-1080x1080.png b/public/banners/iap-1080x1080.png new file mode 100644 index 0000000..034a550 Binary files /dev/null and b/public/banners/iap-1080x1080.png differ diff --git a/public/banners/iap-1080x1920.png b/public/banners/iap-1080x1920.png new file mode 100644 index 0000000..a6392a3 Binary files /dev/null and b/public/banners/iap-1080x1920.png differ diff --git a/public/banners/iap-120x600.png b/public/banners/iap-120x600.png new file mode 100644 index 0000000..7573763 Binary files /dev/null and b/public/banners/iap-120x600.png differ diff --git a/public/banners/iap-125x125.png b/public/banners/iap-125x125.png new file mode 100644 index 0000000..7963ef5 Binary files /dev/null and b/public/banners/iap-125x125.png differ diff --git a/public/banners/iap-1280x720.png b/public/banners/iap-1280x720.png new file mode 100644 index 0000000..e4f021f Binary files /dev/null and b/public/banners/iap-1280x720.png differ diff --git a/public/banners/iap-160x600.png b/public/banners/iap-160x600.png new file mode 100644 index 0000000..cf51098 Binary files /dev/null and b/public/banners/iap-160x600.png differ diff --git a/public/banners/iap-300x250.png b/public/banners/iap-300x250.png new file mode 100644 index 0000000..bb99bba Binary files /dev/null and b/public/banners/iap-300x250.png differ diff --git a/public/banners/iap-320x50.svg b/public/banners/iap-320x50.svg new file mode 100644 index 0000000..a2d7504 --- /dev/null +++ b/public/banners/iap-320x50.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + Earn Instantly + + + Join Free + diff --git a/public/banners/iap-336x280.png b/public/banners/iap-336x280.png new file mode 100644 index 0000000..942f40e Binary files /dev/null and b/public/banners/iap-336x280.png differ diff --git a/public/banners/iap-468x60.png b/public/banners/iap-468x60.png new file mode 100644 index 0000000..be46880 Binary files /dev/null and b/public/banners/iap-468x60.png differ diff --git a/public/banners/iap-728x90.png b/public/banners/iap-728x90.png new file mode 100644 index 0000000..682ebc3 Binary files /dev/null and b/public/banners/iap-728x90.png differ diff --git a/public/banners/iap-advertise-earn-1200x630.jpg b/public/banners/iap-advertise-earn-1200x630.jpg new file mode 100644 index 0000000..1797717 Binary files /dev/null and b/public/banners/iap-advertise-earn-1200x630.jpg differ diff --git a/public/banners/iap-advertise-earn-300x250.png b/public/banners/iap-advertise-earn-300x250.png new file mode 100644 index 0000000..c2926af Binary files /dev/null and b/public/banners/iap-advertise-earn-300x250.png differ diff --git a/public/banners/iap-advertise-earn-336x280.png b/public/banners/iap-advertise-earn-336x280.png new file mode 100644 index 0000000..357ff17 Binary files /dev/null and b/public/banners/iap-advertise-earn-336x280.png differ diff --git a/public/banners/iap-advertise-earn-728x90.png b/public/banners/iap-advertise-earn-728x90.png new file mode 100644 index 0000000..160e38a Binary files /dev/null and b/public/banners/iap-advertise-earn-728x90.png differ diff --git a/public/banners/iap-advertise-earn-v2-336x280.png b/public/banners/iap-advertise-earn-v2-336x280.png new file mode 100644 index 0000000..f788795 Binary files /dev/null and b/public/banners/iap-advertise-earn-v2-336x280.png differ diff --git a/public/banners/iap-hero-1200x630.png b/public/banners/iap-hero-1200x630.png new file mode 100644 index 0000000..54e461a Binary files /dev/null and b/public/banners/iap-hero-1200x630.png differ diff --git a/public/banners/iap-instant-payments-tiers-1200x630.jpg b/public/banners/iap-instant-payments-tiers-1200x630.jpg new file mode 100644 index 0000000..97e7bd4 Binary files /dev/null and b/public/banners/iap-instant-payments-tiers-1200x630.jpg differ diff --git a/public/banners/iap-instant-payments-tiers-v2-1200x630.jpg b/public/banners/iap-instant-payments-tiers-v2-1200x630.jpg new file mode 100644 index 0000000..87ad89a Binary files /dev/null and b/public/banners/iap-instant-payments-tiers-v2-1200x630.jpg differ diff --git a/public/banners/iap-launch-doors-1080x1080.jpg b/public/banners/iap-launch-doors-1080x1080.jpg new file mode 100644 index 0000000..86f8bc9 Binary files /dev/null and b/public/banners/iap-launch-doors-1080x1080.jpg differ diff --git a/public/banners/iap-launch-doors-1200x630.jpg b/public/banners/iap-launch-doors-1200x630.jpg new file mode 100644 index 0000000..8f06ea9 Binary files /dev/null and b/public/banners/iap-launch-doors-1200x630.jpg differ diff --git a/public/banners/iap-ledger-468x60.png b/public/banners/iap-ledger-468x60.png new file mode 100644 index 0000000..c90ecaf Binary files /dev/null and b/public/banners/iap-ledger-468x60.png differ diff --git a/public/banners/iap-ledger-728x90.png b/public/banners/iap-ledger-728x90.png new file mode 100644 index 0000000..ee782de Binary files /dev/null and b/public/banners/iap-ledger-728x90.png differ diff --git a/public/banners/iap-multistream-info-1200x630.jpg b/public/banners/iap-multistream-info-1200x630.jpg new file mode 100644 index 0000000..3f210b1 Binary files /dev/null and b/public/banners/iap-multistream-info-1200x630.jpg differ diff --git a/public/banners/iap-multistream-info-v2-1200x630.jpg b/public/banners/iap-multistream-info-v2-1200x630.jpg new file mode 100644 index 0000000..da2c9b1 Binary files /dev/null and b/public/banners/iap-multistream-info-v2-1200x630.jpg differ diff --git a/public/banners/iap-success-path-coaching-1200x630.jpg b/public/banners/iap-success-path-coaching-1200x630.jpg new file mode 100644 index 0000000..9f70d8e Binary files /dev/null and b/public/banners/iap-success-path-coaching-1200x630.jpg differ diff --git a/public/banners/iap-success-path-info-1200x630.jpg b/public/banners/iap-success-path-info-1200x630.jpg new file mode 100644 index 0000000..dd76caf Binary files /dev/null and b/public/banners/iap-success-path-info-1200x630.jpg differ diff --git a/public/banners/iap-success-path-link-1200x630.jpg b/public/banners/iap-success-path-link-1200x630.jpg new file mode 100644 index 0000000..43b08a4 Binary files /dev/null and b/public/banners/iap-success-path-link-1200x630.jpg differ diff --git a/public/banners/iap-team-build-link-300x250.jpg b/public/banners/iap-team-build-link-300x250.jpg new file mode 100644 index 0000000..70859c8 Binary files /dev/null and b/public/banners/iap-team-build-link-300x250.jpg differ diff --git a/public/banners/iap-team-build-tiers-1200x630.jpg b/public/banners/iap-team-build-tiers-1200x630.jpg new file mode 100644 index 0000000..a2d59d1 Binary files /dev/null and b/public/banners/iap-team-build-tiers-1200x630.jpg differ diff --git a/public/banners/iap-team-build-tiers-v2-1200x630.jpg b/public/banners/iap-team-build-tiers-v2-1200x630.jpg new file mode 100644 index 0000000..5ad85b2 Binary files /dev/null and b/public/banners/iap-team-build-tiers-v2-1200x630.jpg differ diff --git a/public/contract.html b/public/contract.html new file mode 100644 index 0000000..4dd0e91 --- /dev/null +++ b/public/contract.html @@ -0,0 +1,148 @@ + + + + +The contract | LinkSpin + + + + + + + + + + + + + + + + + +
    +
    +

    The contract, in plain language.

    +

    Every dollar on this platform moves through one smart contract. This page explains + what it does, what nobody can change, and exactly what powers we kept. Verify every claim + yourself. That is the point.

    +

    + address: + +

    +

    + Raw contract on the explorer ↗ + Verified source code ↗ +

    +
    + +
    +

    The six laws the code enforces

    +
      +
    • It never holds funds. Every purchase is fully paid out in the same transaction. The contract balance is zero after every sale. There is nothing to freeze, drain, or run away with.
    • +
    • The compensation rules are constants. 50 percent, 20 percent, 10 percent, 20 percent platform. They are compiled into the bytecode. No function exists to change them.
    • +
    • No upgrade path, no pause switch, no self-destruct. The deployed bytecode is the program forever.
    • +
    • Purchased credits only ever go down by delivering your ads. No function reduces them for any other reason, and nothing can mint them except a purchase.
    • +
    • Qualification is earned, never bought. Deeper levels unlock only by referring real buyers of $20 or more. No spend-based shortcuts exist.
    • +
    • Everything is observable. Every state change emits a public event. The website is a mirror of the chain, never the source of truth for money.
    • +
    +
    + +
    +
    +

    What the operator CAN do

    +
      +
    • Add ad packages to the catalog (price floor $1, ceiling $500)
    • +
    • Queue a price change, which waits behind a public 24-hour timelock before anyone can apply it
    • +
    • Retire a package from sale, and reactivate it later
    • +
    • Rotate the fee-receiver, ad-engine, and owner addresses (key-loss insurance)
    • +
    • The ad engine can burn credits, but only as your campaigns consume delivery
    • +
    +
    +
    +

    What the operator CANNOT do

    +
      +
    • Change any split percentage or qualification threshold
    • +
    • Pause, upgrade, or replace the contract
    • +
    • Hold, redirect, or claw back anyone's payout
    • +
    • Mint credits, take credits, or touch anyone's membership record
    • +
    • Move a price outside the $1 to $500 bounds, or skip the 24-hour notice
    • +
    +
    +
    + +
    +

    Where every purchase goes

    +
    +
    50%
    direct sponsor
    +
    20%
    level 2
    +
    10%
    level 3
    +
    20%
    platform
    +
    +

    On a $20 package: $10.00 to the direct sponsor, $4.00 to level 2, $2.00 to level 3, + $4.00 to the platform. Rounding dust of a few billionths of a cent goes to the platform wallet so the + books always balance to zero. When a level has no qualified recipient, that share visibly passes up + to the next qualified person; if none exists within 25 candidates, it goes to the platform. Every one + of these movements is an event on the live ledger.

    +
    + +
    +
    +

    Dollar prices, POL settlement

    +

    Packages are priced in dollars and settled in POL using the Chainlink POL/USD + oracle at the moment of purchase. If you send slightly too much because the rate moved, the excess + refunds to you in the same transaction. If the oracle ever goes quiet, the contract keeps selling + at its last fresh price for up to 24 hours, then new purchases pause until the feed returns. + Settled money, credits, and memberships are never affected by an oracle outage.

    +
    +
    +

    Nobody can stall it

    +

    Payouts are pushed with a strict gas allowance. A wallet that refuses to + accept payment is simply treated as unqualified and its share passes up. No escrow forms, no + purchase reverts, nobody waits on anybody. One practical note: use a normal wallet address for + payouts. Some exotic smart-contract wallets cost more gas to receive than the allowance and would + be passed over.

    +
    +
    + +
    +

    How it was tested

    +

    Before deployment the contract passed a suite of 24 tests covering every split + scenario, the pass-up walk to its exact 25-candidate boundary, oracle outages and price swings, + refunds, hostile recipient wallets, and catalog rules. On top of that, an invariant fuzzer ran + 128,000 randomized transactions and confirmed after every single one: the contract balance stayed + zero, every wei in equaled every wei out, credits equaled purchases minus delivery, and nobody was + qualified without earning it. The full specification was then audited line by line against the code. + The source you see at the verified-source link is byte-for-byte what runs on chain.

    +
    + +
    +
    +

    Do not trust this page. Check it.

    +

    The whole reason this platform exists is that you should + not have to take anyone's word, including ours. Open the source, open the ledger, click a + transaction.

    + Read the verified source + Open the live ledger +
    +
    +
    contract
    +
    +
    compiler checkexact match
    +
    bytecode checkexact match
    +
    upgrade pathnone
    +
    pause switchnone
    +
    +
    +
    + +
    +
    LinkSpin · how it works · live ledger
    +
    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/disclaimer.html b/public/disclaimer.html new file mode 100644 index 0000000..6332a8c --- /dev/null +++ b/public/disclaimer.html @@ -0,0 +1,32 @@ + + + + +Disclaimer | LinkSpin + + + + + + +
    +

    Risk & Earnings Disclaimer

    Last updated: September 2026

    +
    +

    No income guarantee

    +

    LinkSpin is an advertising service with a referral program. It is not an investment, a security, or a passive-income scheme. We do not promise, project, or guarantee any earnings. Any figures shown in examples or sample creatives are illustrations only, not typical or expected results. Most participants should expect to earn little or nothing unless real advertising is purchased in their line.

    +

    Not financial or legal advice

    +

    Nothing on the Platform is investment, financial, tax, or legal advice. Do your own research and consult a professional before spending money.

    +

    Crypto risk

    +

    Purchases settle on a public blockchain using a volatile network token. On-chain transactions are final and irreversible: there are no refunds, chargebacks, or reversals. You are responsible for your wallet, your keys, network fees, and the tax treatment of your activity.

    +

    Live on Polygon

    +

    The Platform is live on the Polygon mainnet. Purchases and payouts use POL, a real cryptocurrency with real monetary value. Every transaction is real and final — this is not a simulation or test environment.

    +

    Advertising

    +

    Ads are member-created and auto-approved for speed. LinkSpin does not endorse and is not responsible for advertised products, sites, or claims. Use your judgment, and report anything broken or inappropriate.

    +

    Your responsibility

    +

    You decide whether, and how much, to spend. Never spend more than you can afford to lose.

    +
    +
    + + + + diff --git a/public/earning.html b/public/earning.html new file mode 100644 index 0000000..aee1e88 --- /dev/null +++ b/public/earning.html @@ -0,0 +1,73 @@ + + + + +How earning works | LinkSpin + + + + + + + + + +
    +
    +

    Member guide

    +

    How earning works, and what to do with it.

    +

    Every free way to earn ad credits, the claim streak, and the one thing that makes the credits worth anything: spending them on a campaign.

    +
    + +
    This page is about credits, which every member earns. POL, the crypto the contract pays to your wallet when your referrals buy packages, is separate: activate with the $20 starter package and switch on payouts, and level 1 pays 50 percent of every package your directs buy.
    +
    One credit is one cent of ad delivery. Credits are not money and cannot be withdrawn. They buy impressions, clicks and visits for your own campaigns across LinkSpin and the partner network. Payouts in POL come only from packages people in your line buy.
    + +

    1. The daily set

    +

    Earn credits › Watch ads. Five ads a day. Each one opens full screen, a 10-second countdown runs while you look, you pass a quick click-the-icon check, and you earn 1 credit. Finish all five and the Claim button appears.

    + +

    2. The claim streak

    +

    The claim pays more the more days in a row you claim it. Miss a day and it restarts at day 1.

    + + + + + + +
    Consecutive dayClaim pays
    Day 15 credits
    Day 27 credits
    Day 3 and on10 credits
    Every 7th day in a row25 credits
    +

    The hint under the set always tells you which day you are on and what tomorrow's claim pays. Days are counted in UTC, so the set resets at 7 PM Central.

    + +

    3. After the set: verified visits

    +

    When the set is claimed, the done screen offers verified visits: up to 20 a day, 1 credit each. You open a member's site in a new tab, stay for the dwell, and the visit counts. Each visit is unique per site per day, so it is 20 different sites, not one site 20 times.

    + +

    4. Videos and inbox ads

    +
      +
    • Videos. Earn credits › Watch videos. You are credited only when the video plays to the end. Up to 6 a day, and the reward depends on the video's length.
    • +
    • Inbox solo ads. Earn credits › Inbox Ads. Open the message, visit the advertiser's link, then claim 2 credits. Once per message.
    • +
    + +

    5. The sign-in bonus and milestones

    +

    Signing in pays 5 credits a day, rising by one for each consecutive day up to 10. Milestone badges pay too: Spark when payouts are on, Surge at your first qualifying buyer, Circuit at two, Nexus at five.

    + +

    6. Now spend it

    +

    Credits sitting in your balance do nothing. Campaigns › New campaign: pick a banner or a text ad, give it a name and a budget in credits, point it at your own invite link or wall, and launch. Banner and text ads also push out to the partner network, so a 100-credit campaign is a hundred cents of real delivery. The done screen has a one-tap button for this after every claim.

    +

    Two things to know: a campaign reserves its whole budget the moment you start it, so your available balance drops right away and never surprises you later, and login ads are the one format that needs purchased credits.

    + +

    No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. LinkSpin sells advertising; it is not an investment.

    + +
    + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..2979bf9 --- /dev/null +++ b/public/index.html @@ -0,0 +1,489 @@ + + + + +LinkSpin: advertise and earn, locked in code + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    + + + + + + +
    +

    Advertise and earn instantly.
    Locked in code, not promises.

    +

    Buy real ad packages from 5 to 250 dollars. Every purchase settles through + a smart contract you can read yourself, and your earnings land in + your own wallet before the page even refreshes.

    +

    One crypto, one network: packages are paid in POL on Polygon and every payout arrives as POL in your wallet. Buy POL with a card inside if you have never held any.

    + + +
    +
    On-chain members
    +
    Packages bought
    +
    POL settled
    +
    Instant payouts
    +
    +

    every number above is read from the blockchain, not a marketing database

    +
    +
    + + + +
    +
    +
    +

    Instant payments without compromise

    +

    Most referral programs run on a database someone can change after you promote. + This one runs on Polygon, split by a contract nobody can touch. Not even us.

    +
    +
    +
    +
    +

    Paid in the same transaction

    +

    The purchase and every payout are one blockchain event. No balances held, no withdrawal + button, no company touching the money.

    +
    +
    +
    +

    Rules that cannot move

    +

    50-20-10 across three levels plus a 20 percent platform fee, written as constants in an + immutable contract. There is no function to change them.

    +
    +
    +
    +

    Verifiable by anyone

    +

    Every payment streams to a public ledger with a verify link straight to the block explorer. + If it is not there, it did not happen.

    +
    +
    +
    +
    + +
    +
    +
    +

    Why this is not another ad site

    +

    You have seen traffic exchanges, click-to-earn sites and solo-ad sellers. Here is the honest side by side.

    +
    +
    + + + + + + + + + + +
    Typical advertising siteLinkSpin
    Referral commission5 to 15 percent, often only after a minimum balance50 percent to the direct sponsor, then 20 and 10 on the next two levels
    When you get paidRequest a withdrawal, wait for approval, hopeIn the same transaction the package sells
    Who holds the moneyThe site's balance, at the owner's discretionNobody. The contract splits it to real wallets. There is no balance to hold
    Can the rules changeWhenever the owner edits a settingNever. 50 / 20 / 10 / 20 are constants in a verified, immutable contract
    ProofA number on a dashboardEvery payout is a public transaction you can open yourself
    Ad viewsTimers that run while nobody looksA 10-second dwell, a human check, and a server-side clock
    CreditsPoints that expire or get devalued1 credit = 1 cent of delivery, recorded on-chain, spent only by your campaigns
    JoiningInstall an app, connect a wallet, then maybe readEmail first. The wallet comes when you are ready to be paid
    AdvertisingPay to be seen by people who are paid to clickSeven formats delivered to members who buy ads themselves, with verified visits and finished video views
    +

    No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. LinkSpin sells advertising; it is not an investment.

    +
    +
    + +
    +
    +
    +

    Watch the money move

    +

    This is a live view, not a brochure. Real purchases split into real payouts, + each one a click away from the raw transaction.

    +
    +
    +
    +
    +
    +

    Buy a package

    +

    Priced in dollars, settled in POL at the live oracle rate. Overpayment refunds itself in the same transaction.

    +
    +
    +
    +

    The contract splits it

    +

    Half to the direct sponsor, then levels 2 and 3, then the platform. Automatically, immediately, every time.

    +
    +
    +
    +
    linkspin-test.saasy.top/ledger
    +
    +
    🧾 member #7 bought package #2 ($20.00)−213 POL
    +
    💸 level 1 payout → member #3+106.5 POL
    +
    💸 level 2 payout → member #2+42.6 POL
    +
    💸 level 3 payout → member #1+21.3 POL
    +
    🏛 platform fee settled42.6 POL
    +
    ⭐ member #3 now has 2 qualifying buyerslevel 2 ✓
    +
    +
    +
    +
    +
    +

    Straight to their wallets

    +

    Recipients get POL in their own wallets within seconds. Nothing to claim, nothing to request.

    +
    +
    +
    +

    Credits mint on-chain

    +

    One credit is one cent of ad delivery across the network. Only your campaigns can ever spend them.

    +
    +
    +
    +

    Amounts shown are the $20 worked example at the current oracle rate. Open the real ledger →

    +
    +
    + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + 50% + + + + + + + + + + + + + + + + + + + + + +
    +
    +

    Earn deeper as your people buy

    +

    Levels unlock by performance, never by payment. Refer buyers, and their + shares of every future purchase route to you automatically.

    +
    + + + +
    +

    Activate with the $20 starter package and switch on payouts from your wallet. From then on your direct referrals each pay you 50 percent of every package they ever buy, in POL, straight to your wallet. Until you activate, you earn ad credits, not POL.

    +
      +
    • No withdrawal requests, ever. Every payment sends straight to your wallet.
    • +
    • Unqualified shares visibly pass up to the next qualified person
    • +
    • Qualification never expires and can never be bought
    • +
    +
    +
    +
    +
    + +
    +
    +
    +

    Real ad inventory. Real eyeballs.

    +

    This is not phantom traffic or empty impressions. Seven ad formats run right now, + reaching members who are themselves marketers buying traffic — and members earn credits back + for the attention they give.

    +
    +
    +
    +
    +

    Display banners live

    +

    All standard sizes, live across our network, priced per impression. You pay for views, not guesses.

    +
    +
    +
    +

    Text ads live

    +

    A headline plus a support line, placed where members actually look. Also per impression, also live right now.

    +
    +
    +
    +

    Login ads live

    +

    The moment a member signs in, your ad is the toll gate: they open your page in a fresh tab + while a countdown holds their dashboard. Just a link is enough — add a banner if you have + one. Priced per day.

    +
    +
    +
    +

    Video ads live

    +

    Upload a video or drop a link, and pick how long members must watch — 10, 30 or 60 seconds. + They watch in a player that can't be skipped, the clock runs on our server, and you pay only + for completed views. There's a full-screen Shorts feed too.

    +
    +
    +
    +

    Solo ads live

    +

    Your full message — rich text, an image or video, and a call-to-action button — delivered + straight into member inboxes on-site and by email. Priced per guaranteed delivery, and readers + earn credits for a real read, so your message gets opened, not skimmed past.

    +
    +
    +
    +

    Featured rotation live

    +

    Book your link into the featured rotation by the day — 1, 2 or 7 days — with a hard cap on how + many links share a day and the occupancy shown before you buy. The only rotator that tells you + the dilution up front, because hiding it is a sucker move.

    +
    +
    +
    +

    Verified visits live

    +

    Buy a pack of guaranteed unique human visits. Each one is a different member who stayed the + full dwell and passed a human check — no bots, no recycled clicks, no repeats, just real visits + you can count on.

    +
    +
    +
    +
    + +
    +
    +
    +

    What you get as a member

    +

    Free membership gets you in the door with a working account and the ability to earn + from day one. Buy any package and the real firepower unlocks.

    +
    +
    +
    +

    Credits: everyone earns these

    +

    Ad credits are the currency of the ad platform. One credit is one cent of ad delivery. They are not money and are never withdrawn.

    +
      +
    • Earned free by viewing ads, watching videos, verified visits, inbox ads and the daily sign-in
    • +
    • Welcome credits on day one, badge bonuses as your team grows
    • +
    • Spent on your own banner, text, video and solo campaigns
    • +
    +
    +
    +

    POL: activated members earn this

    +

    POL is real crypto, paid to your own wallet by the contract in the same transaction someone in your line buys a package. Nobody holds it for you.

    +
      +
    • Activate: the $20 starter package (2,000 credits to advertise with) and payouts switched on from your wallet
    • +
    • Level 1 pays 50 percent of every package your direct referrals buy, from the day you are activated
    • +
    • Levels 2 and 3 open when 2, then 5, of your referrals buy a $20 or larger package
    • +
    • If you are not activated when someone in your line buys, that share passes up to the next member above you who is
    • +
    +
    +
    +
    +
    +

    Free membership includes

    +
      +
    • A member account and the live ledger
    • +
    • Welcome credits to taste real ad delivery
    • +
    • Earn more credits by viewing ads, watching videos, making verified visits and reading solo ads
    • +
    • Your line banner, shown to your next three levels as they join
    • +
    • Your own shareable profile page with a scannable join QR code
    • +
    • Achievement badges and credit bonuses as your team grows
    • +
    • Your personal referral link, working from day one
    • +
    • Your invite link and line from day one; activate with the $20 starter package to earn POL on your referrals' purchases
    • +
    +
    +
    +

    Any package adds

    +
      +
    • On-chain ad credits minted the moment you buy
    • +
    • All seven ad formats, with live stats and a dashboard of charts per campaign
    • +
    • Top up any campaign anytime, and message your whole downline
    • +
    • A full arsenal of promotional tools and a ready-made banner kit, personalized with your link
    • +
    • Packages of $20 or more count toward qualification
    • +
    +
    +
    +
    +
    + +
    +
    +
    +

    The ad packages

    +

    Priced in dollars, settled in POL at the moment you buy. Packages of $20 or more + count toward your sponsor's qualification.

    +
    +
    Loading live prices from the contract…
    +
    +
    50%
    direct sponsor
    +
    20%
    level 2
    +
    10%
    level 3
    +
    20%
    platform
    +
    +
    +
    + +
    +
    +
    +

    Run your what-if

    +

    Play with a scenario and see how the contract would split it. This is arithmetic on the + locked percentages, not a prediction and not a promise. Nobody earns a cent unless real + people really buy advertising.

    +
    +
    +
    +
    +

    Direct referrals who each buy a package

    +

    + 2

    +

    The package they buy

    +

    +

    Referrals each of them brings who also buy

    +

    + 2

    +

    +
    +
    +
    + + + + + + + +
    LevelPeople buyingYour shareYou receive
    Level 1 open50%
    Level 2 locked20%
    Level 3 locked10%
    If every one of those purchases happens
    +

    And that is one round of purchases. The same + split runs again on every future package the same people buy.

    +
    +
    +

    + The pass-up rule: a locked level's share does not disappear. + The contract climbs the sponsor line, checking up to 25 positions, and pays the first qualified + person it finds; only if nobody in those 25 qualifies does the share go to the platform. It cuts + both ways: stay qualified and you catch the shares that under-qualified positions below you let + slip. Every pass-up is a visible event on the ledger, so you can see + exactly where money climbed past someone and why.

    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +

    Questions people actually ask

    +
    +
    Is this a pyramid or ponzi scheme? +

    No, and here is the plain version of why. A pyramid scheme pays you just for recruiting, with no + real product behind it. Here every payment is for ad delivery, and the payouts only come from real + buyers who buy real ad packages. You earn from people actually advertising, not from building a chain + of signups. The whole thing runs on a public ledger you can verify yourself, so nothing stays hidden.

    +
    What happens if the company disappears? +

    The important part is that there is no company holding the money. Every sale is split by the smart + contract itself, on the blockchain, in the same transaction. Nobody can pause it, change it, or run + off with the funds, because the code does the paying automatically. If the site vanished tomorrow, + the contract would keep paying exactly what it is set to pay.

    +
    Do I need crypto experience or a wallet to join? +

    No. You can start with a free email signup and use the platform the same way you would use any ad + site. If you want payouts to a wallet, setting one up takes a few minutes and the platform walks you + through it. But you do not need to know anything about crypto to get in and get moving.

    +
    How fast do I really get paid? +

    Payouts happen inside the very transaction that pays for the package. There is no approval step + and nobody holding funds. Your share lands in your own wallet the moment your referral's purchase + confirms. The public ledger lets you watch each one go through.

    +
    What am I actually buying? +

    You are buying ad delivery. Credits are one cent of delivery each, recorded on-chain when you buy. + The 5 dollar package mints 500 credits, and the bigger packages mint bonus credits on top, up to + 32,500 on the 250 dollar package. That is the product, plain and simple. Your earnings come from + referring people who also buy real ad delivery, and the contract splits every purchase automatically.

    + +
    +
    +

    See a payment land before you decide.

    +

    The ledger is open to everyone. Watch real purchases + split and settle, then join free when you have seen enough.

    + Join free + Open the live ledger +
    +
    +
    live · polygon
    +
    +
    💸 payout → member #3instant
    +
    💸 payout → member #2instant
    +
    🔍 verify on explorer
    +
    +
    +
    + +
    +
    LinkSpin · every payment verifiable on-chain · live ledger · view the contract ↗
    +
    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/join.html b/public/join.html new file mode 100644 index 0000000..45711d3 --- /dev/null +++ b/public/join.html @@ -0,0 +1,163 @@ + + + + + +You're invited | LinkSpin + + + + + + +
    +
    + LinkSpin + Already a member? Sign in +
    + +
    + +

    InstantAdPay · Advertise and earn on Polygon

    + +

    Advertise and earn.
    Paid on-chain, instantly.

    +

    Every ad package splits to real wallets in the same transaction it sells. No pending payouts, no withdraw button, and every payment is public.

    +
    + +
    +
    + +
    +
    +
    linkspin-test.saasy.top/ledger · worked example
    +
    +
    🧾 member #7 bought package #2 ($20.00)paid
    +
    💸 level 1 payout → member #3 (50%)same block
    +
    💸 level 2 payout → member #2 (20%)same block
    +
    💸 level 3 payout → member #1 (10%)same block
    +
    🏛 platform fee settled (20%)same block
    +
    ⭐ member #3 now has 2 qualifying buyerslevel 2 ✓
    +
    +
    +
    +

    One purchase, one transaction, four payments. Open the real ledger →

    + +
    + +
    +

    Join free

    +

    Type your email and we send a 6-digit code. No password, no wallet needed today.

    +

    + + + + + + + + + +

    Advertising, not investing. No income is guaranteed; results depend on your effort. Crypto carries risk of loss.

    +
    + +
    + +
    +
    + Paid in POL, Polygon's own coin, to your wallet + Same transaction payouts + Public ledger on Polygon + Free to join by email +
    +

    The one crypto here is POL on the Polygon network: packages are paid in POL and every payout arrives as POL in your own wallet. Any Polygon wallet works (MetaMask, SafePal, Phantom, Coinbase Wallet). Never held crypto? Buy POL with a card inside the member area.

    +
    +
    +

    How it works

    Three steps. The first one takes a minute and costs nothing.

    +
    +
    STEP 1

    Join free by email

    A 6-digit code, no password. You get an invite link and welcome credits to try real ads.

    +
    STEP 2

    Advertise or earn

    Seven ad formats. View ads to earn credits, or buy a package from $5 when you want reach.

    +
    STEP 3

    Get paid in the same transaction

    Activate with the $20 starter package and switch on payouts, and anyone who buys through your link pays you 50 percent, on-chain, the moment it happens.

    +
    +
    +
    +

    The packages

    Priced in dollars, settled in POL at the live rate. One credit is one cent of ad delivery.

    +
    +

    Every package pays 50 / 20 / 10 up the line the moment it sells, on a public ledger. Join free and look around first.

    +
    + +
    + LinkSpin · Contract · Terms · Privacy · Disclaimer +
    +
    + + + + diff --git a/public/launch.html b/public/launch.html new file mode 100644 index 0000000..bb84d01 --- /dev/null +++ b/public/launch.html @@ -0,0 +1,110 @@ + + + + +Founding week checklist | LinkSpin + + + + + + + + + +
    +
    +

    Leaders · founding week

    +

    Eight things before launch day.

    +

    Get these done this week, in this order, and your first commissions land in your wallet instead of climbing past you. Everything here reads from your live account.

    +
    + +

    Members only

    Sign in to your member area to open the checklist. Sign in

    + +
    +
    +
    0 of 8 ready
    + + +
    + +
    + The one rule that makes this week matter: unqualified levels pass up. + Level 2 pays you only after two of your people have bought a $20 or more package. Level 3 only after five. If your team's team starts buying before you are qualified, those 20% and 10% payments climb to the next qualified member above you, or to the platform, and they never come back. Qualify first, then open the doors. +
    + +
      + +

      The week, day by day

      +
      +
      Day 1
      • Items 1 to 3 done in one sitting.
      • Decide your play, and whether you are going for level 2 or all three.
      +
      Days 2 to 3
      • Qualify: two real buyers, or Qualified Start. Leaders: go to five and open level 3.
      • Line banner uploaded.
      +
      Days 4 to 6
      • Place your first two personally.
      • Walk them through items 1 to 3 on their accounts.
      +
      Launch day
      • Everyone releases links at the same time.
      • Watch the ledger and the Telegram proof feed fill.
      +
      + +

      What to send your two this week

      +
      Text or DM · before launchI'm bringing a small group in early on something before it opens publicly next week. Free to join, real advertising, and every payment lands in your own wallet the second it happens. I want you positioned before the doors open. Set up takes five minutes: your link
      +
      Text or DM · after they joinThree quick things before launch so your first commission lands with you and not past you: pick your username, link your wallet, switch on payouts. All on the Wallet and Profile tabs. Then send me your two names and we'll get them placed.
      + +

      Launch graphics and posts

      +

      Two images, no text on them but the headline, so they work anywhere. Post one with the day's line below it; your link and the FOUNDER code are already in each line.

      +
      +
      Doors open Monday
      Wide, 1200x630 · for X, Facebook, LinkedIn, link previews Download
      +
      Doors open Monday
      Square, 1080x1080 · for Instagram, Telegram, WhatsApp Download
      +
      +
      + +

      Launch week swipes: four emails for your list

      +

      Send one a day to your own list or contacts, in this order. Your invite link and the FOUNDER code (500 free credits for anyone who joins before Monday 9 AM Central) are already filled in. Copy, paste, send from your own email. Edit anything you like.

      +
      + +

      No income is guaranteed. Results depend on your effort. Crypto carries risk of loss. LinkSpin sells advertising; it is not an investment.

      +
      + + +
      + + + + diff --git a/public/ledger.html b/public/ledger.html new file mode 100644 index 0000000..2d52cf0 --- /dev/null +++ b/public/ledger.html @@ -0,0 +1,44 @@ + + + + +Live ledger | LinkSpin + + + + + + + + + + + + + + + + + +
      +
      +

      The ledger does not lie.

      +

      This page streams every payment the contract has ever made. If it is not here, + it did not happen. Every line carries a verify link straight to the block explorer. Go click one.

      +

      connecting… +

      +
      + +
      +
      Loading recent history…
      +
      + + +
      + + + + + diff --git a/public/logo-icon.png b/public/logo-icon.png new file mode 100644 index 0000000..87a53d8 Binary files /dev/null and b/public/logo-icon.png differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..597ee8f Binary files /dev/null and b/public/logo.png differ diff --git a/public/my.html b/public/my.html new file mode 100644 index 0000000..cacf168 --- /dev/null +++ b/public/my.html @@ -0,0 +1,1056 @@ + + + + +Member area | LinkSpin + + + + + + + +

      Loading your account…

      + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/partners.html b/public/partners.html new file mode 100644 index 0000000..3eba511 --- /dev/null +++ b/public/partners.html @@ -0,0 +1,134 @@ + + + + +For site owners | LinkSpin + + + + + + + + + +
      +
      +

      For site owners with a downline builder

      +

      I built the ad platform I always wanted to run. I want it in your builder.

      +

      A note from me, plus everything you need to list LinkSpin in your builder and hand your members free ad credits with a code of your own.

      +
      + +

      + +
      +

      Watch first: the whole platform in about five minutes. Free and paid members, the seven ad formats, how people get paid, and the offer for your builder.

      + +

      Why I built it

      +

      You know I've been running ad sites for years, the kind your members already know: buy a package, run banners and text ads, click for credits. Two of mine, Faucet Wave and Tier One Ads, ran on a licensed script. The vendor went out of business, their license server went dark, and it crippled licenses that were fully paid. The sites died overnight and nothing I could do would bring them back.

      +

      So I built LinkSpin from scratch as part of the Crypto Team Build Network. No vendor, no license server, and the part that always went wrong on ad sites, the money, is handled by a verified smart contract on Polygon instead of by me. When a package sells, the contract splits the payment and sends it in the same transaction. I never hold member funds, so there is no back office, no payday, and nothing anyone can switch off.

      +

      Marty Bostick · Crypto Team Build Network

      + +

      What LinkSpin is

      +

      An advertising platform where the ad spend in your line pays you. Members join free with an email address, no password and no wallet on day one. They earn credits by viewing ads and can run their first campaign for zero dollars. When they want more reach they buy an ad package, and every package that sells is split by the contract the moment it sells.

      +
      +

      Seven ad formats

      Banners, text ads, login ads, solo ads to member inboxes, video, featured links and verified visits. Views are timed on the server, so a real person saw the ad.

      +

      Beyond the site

      Banner and text ads also push out to Network Ad Space, a partner rotation across other member sites. Those impressions count in the member's stats.

      +

      WalletConnect built in

      Full WalletConnect integration: members link MetaMask, Trust, Phantom, SafePal or any WalletConnect wallet with one tap, sign once, and buy or get paid straight from that wallet. No custody on my side.

      +

      Public, verified, immutable

      Every payout is a public transaction on Polygon. The split percentages and qualification rules are constants in a verified contract that the operator cannot change.

      +
      + +

      How the money moves

      +

      Every ad package splits the same way, in the same transaction it sells in:

      +
      +
      50%
      direct sponsor
      +
      20%
      level 2
      +
      10%
      level 3
      +
      20%
      platform
      +
      +

      On a $20 package: $10 to the direct sponsor, $4 to level 2, $2 to level 3, $4 to the platform. If a level has no qualified member, that share passes up to the next qualified person above.

      +

      Qualification is earned, never bought. Every direct buyer pays their sponsor 50% from their very first package. Two qualifying buyers, people who bought a $20 or larger package, open level 2. Five open level 3. Until a level opens, its share climbs to the next qualified member above, which is why the plan rewards the people who actually build.

      + + + + + + + +
      PackagePriceCredits
      Micro$5500
      Activation, the qualifying buy$202,000
      Builder$505,500
      Growth$10012,000
      Leader$25032,500
      + +

      What your members get

      +
        +
      • A free start. Join by email, view a few ads, earn credits, run a real banner or text campaign for nothing.
      • +
      • Your promo credits on top. Members who arrive with your code get free ad credits added the moment they join, in addition to everything else.
      • +
      • Instant, public payouts. When anyone in their line buys ads, the contract pays them in POL to their own wallet in the same transaction. They can check every payment on Polygonscan.
      • +
      • A seamless wallet step. Full WalletConnect integration means linking a wallet is one tap and one free signature from any major wallet app, and purchases confirm inside the wallet they already use.
      • +
      • Tools that do the work. A ready-to-send invite message, social posts, email swipes, a full banner kit, objection answers, and a public profile wall with their own banner slots.
      • +
      • Training that keeps growing. A video series that walks the whole member area, plus written plays for building a line.
      • +
      • A holding tank. Members who arrive without a sponsor are not lost. Qualified builders adopt them, first come, first served.
      • +
      • Coaching built in. A next-move card on every dashboard, nudges when a referral stalls, and a live payments topic on Telegram where every payout posts as it lands.
      • +
      + +
      +

      What you get for listing it in your builder

      +
        +
      • Your spot at the top. You join directly under the company at the top, no sponsor in between. All it takes to claim that spot is activating your account with at least the $20 package, and that locks you in at the top of your own line from day one.
      • +
      • Your own promo code. A reusable code that adds free ad credits for every member who redeems it, on your builder link or in the dashboard. I set the credit amount, an optional cap and an optional expiry. One redemption per account, every redemption logged, and you can see uses any time.
      • +
      • Your own line. Every member who comes through your builder link lands under you. Each one who buys pays you 50% of their first package and every package after it, and their buyers open your level 2 and level 3 shares.
      • +
      • Ready-made creatives. Banners in every builder size (468x60, 728x90, 300x250, 160x600, 120x600, 1200x630), text ad copy, email swipes and a program description you can paste into your listing.
      • +
      • A bridge page for your brand, on request. A landing page in this design that names your site, explains the connection, and carries your code.
      • +
      • Attribution you can check. Signups and buyers are tagged with the source they came from, and the promo code report shows exactly who redeemed yours.
      • +
      +
      + +

      Setting it up takes about fifteen minutes

      +
        +
      1. Claim your spot with the button below. It places you directly under the company at the top. Join with your email and pick your username; your own invite link is live immediately: linkspin-test.saasy.top/join/yourname.
      2. +
      3. Send me your username and the site you are listing it on. I mint your code with the credit amount we agree on.
      4. +
      5. Add LinkSpin to your downline builder with your link plus the code: https://linkspin-test.saasy.top/join/yourname?promo=YOURCODE. Members who click it land under you and their credits apply the moment their account exists.
      6. +
      7. Use the banners and text ads from the kit. Members who already have an account can type the code into the "Have a promo code?" box on their Overview.
      8. +
      9. Link a wallet, switch on payouts, and activate with at least the $20 package. That claims your spot at the top and makes you a qualifying buyer in your own right.
      10. +
      + +
      + Claim your spot at the top +

      Free account by email. No password, no wallet today. The link below places you directly under the company.

      + Claim my spot +
      + +

      The honest part

      +

      LinkSpin sells advertising. Members earn from the ad packages people in their line buy, and nothing else. There is no earn-without-referring option, on purpose, because sites that pay you just for buying in are the ones that collapse. No income is guaranteed, results depend on effort, and cryptocurrency involves risk of loss. The contract, the ledger and every payout are public, so you never have to take my word for any of it.

      +

      Marty Bostick · marty@marketingwithmarty.com · t.me/cryptoteambuild

      + +
      +
      LinkSpin · home · live ledger · the contract
      +
      Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford to lose.
      +
      +
      + + + + diff --git a/public/plays.html b/public/plays.html new file mode 100644 index 0000000..156774b --- /dev/null +++ b/public/plays.html @@ -0,0 +1,198 @@ + + + + +Team-building plays | LinkSpin + + + + + + + + + +
      +
      +

      Member training

      +

      Three ways to build a line.

      +

      Pick one and run it. Every number here comes from the live contract and the rate table, not from a slide.

      +
      + +

      Members only

      Sign in to your member area to read the plays. Sign in

      + +
      +
      + The one rule under all three plays: unqualified levels pass up. + If someone on your level 2 buys before you have two qualifying buyers, that 20% does not wait for you. It goes to the next qualified sponsor above you, or to the platform. Same for level 3 and five. Whatever play you run, the first job is the same: get qualified before your line gets busy. +
      + +

      How a package splits, the moment it sells

      +
      +
      50%
      Direct sponsor
      +
      20%
      Level 2 · needs 2
      +
      10%
      Level 3 · needs 5
      +
      20%
      Platform
      +
      +

      The ladder on your Overview, and what each rung unlocks

      +
      +
      Rung 1
      Joined
      Welcome tour: 25 credits
      +
      Rung 2
      Payouts on
      Spark badge · 10 credits
      +
      Rung 3
      First buyer
      Surge · 25 credits · 50% starts
      +
      Rung 4
      2 qualifying
      Circuit · 50 credits · level 2 · wall position 2
      +
      Rung 5
      5 qualifying
      Nexus · 100 credits · level 3 · wall position 3
      +
      + +
      +

      Opening move · Qualified Start

      works with any play
      +

      Fits: anyone who would rather start qualified than wait for their first two buyers.

      +

      Qualification is earned by buyers, never bought. But you can be your own first buyers, openly. Under Buy packages, link a second wallet you own as a position. When it buys a $20 package the contract counts it as a qualifying buyer, half the purchase comes straight back to your main wallet, and its credits pool with yours. Two positions open level 2 the same day; five open level 3. Then run whichever play fits you with the ladder already climbed. The three Qualified Start videos in Training show every click.

      +
      +
      Net cost
      Level 2: about $20 net for $40 of ad credits. Level 3: about $50 net for $100 of credits. Plus a little POL for gas in each wallet.
      +
      Say it plainly
      Your own money, your own wallets, a faster start. Never an income promise: qualification only pays on future purchases in your line.
      +
      +
      + +
      +

      Play 1 · Wide and teach

      the fifty play
      +

      Fits: someone with an audience, a list, a group, or traffic they can point somewhere.

      +

      Every direct who buys is 50% to you, instantly, forever. Directs are the only thing that qualifies you. The teaching is what fills levels 2 and 3 without extra work from you: your directs' buyers are your 20%, their buyers are your 10%.

      +
        +
      1. One new conversation a day, minimum. Text a friend and Social posts in Promo tools already carry your link.
      2. +
      3. Send paid traffic to an angle lander, not the bare link. Add ?v=adspend for advertisers, ?v=free for freebie seekers, ?v=instant for the crypto-curious.
      4. +
      5. Every new direct gets the same three sentences inside 24 hours (sponsor chat or the daily broadcast): pick your username, link your wallet and switch on payouts, send your link to one person today. That is the whole teaching. They pass it down.
      6. +
      7. Run the network's own ads at your link. Buy a package or claim the daily 5 credits, then spend credits on a Featured link (40 credits a day) or a text ad pointed at your angle lander.
      8. +
      +
      +
      Scoreboard
      Joined your line climbing daily · Qualifying buyers 2, then 5 · level 2 and 3 rows appearing in My line
      +
      Ceiling and weakness
      No ceiling on width. Shallow lines churn if you skip step 3.
      +
      +
      + +
      +

      Play 2 · Two, then down

      the depth play
      +

      Fits: someone with a small circle who would rather coach two people well than pitch twenty.

      +

      Two qualifying buyers open level 2, wall position 2, the Circuit badge and 50 bonus credits. From there every person your two bring in pays you 20%, and every person those people bring in pays you 10% once you reach five. Your effort goes into two relationships instead of a funnel.

      +
        +
      1. Get two directs to a $20+ package. Sit with them on the buy if you have to. Trust Wallet needs a POL cushion; SafePal or MetaMask are smoother.
      2. +
      3. Coach them to their two. Sponsor chat daily for the first week. One broadcast a day to your directs with a single ask each time.
      4. +
      5. Set your line banner to your team's meeting place (a Telegram group, a training page). Every new member three levels down meets it on their welcome tour.
      6. +
      7. Keep adding directs until you have five. This is the catch: level 3 only opens on five qualifying directs of your own. Two deep, coached well, earns a healthy 20% level. It does not open the 10% level.
      8. +
      +
      +
      Scoreboard
      Qualifying buyers 2 · level 2 count in My line rising · your directs' own qualifying counts
      +
      Ceiling and strength
      Level 2 income until you personally hit five. The stickiest lines come from this play.
      +
      +
      + +
      +

      Recommended default

      +

      Play 3 · Five and wide

      the combination
      +

      Fits: anyone willing to do both. This is the play the dashboard ladder is actually built for.

      +
        +
      1. Sprint to five qualifying directs. Nothing else matters until level 3 is open: Nexus, wall position 3 (your whole public page runs your own links), 100 bonus credits, and the full 50 / 20 / 10.
      2. +
      3. Then split the day. Mornings wide: one new conversation, one post, one ad running. Evenings deep: read My line, message the three newest directs, send the broadcast.
      4. +
      5. Coach the 2-then-5 rule down the line. Each of your five gets pushed to two (your level 2 fills), then to five (your level 3 fills). Use the achievements Share links; people copy what they see rewarded.
      6. +
      7. Book the featured strip for 7 days whenever you have 280 credits spare. Ten slots a day, every member sees it.
      8. +
      +
      +
      Scoreboard
      All four Overview tiles, plus Earning levels: buyers referred, level open, how many to next
      +
      Why it wins
      Width qualifies you. Depth pays you on other people's effort. Only this play does both on purpose.
      +
      +
      + +
      +

      Which play fits you

      +
      + + + + +
      You haveRunFirst target
      A list, a group, or ad budgetWide and teachFive qualifying directs in 30 days
      A few close people and patienceTwo, then downTwo qualifying directs in 14 days, both coached to their two
      An hour a day and a phoneFive and wideFive qualifying, then one wide and one deep action every day
      +
      + +
      +

      First 30 days, any play

      +
      +
      Day 1
      Username. Wallet linked. Payouts on. Welcome tour done (25 credits). Link sent to one person.
      +
      Days 2 to 7
      One conversation a day. Claim the daily 5 credits. First buyer (25 bonus credits).
      +
      Days 8 to 14
      Second qualifying buyer. Level 2 open. Line banner set. First broadcast sent.
      +
      Days 15 to 30
      Coach the two to their two. Add directs three, four, five. Level 3 open by day 30 is the stretch goal.
      +
      +
      + +
      +

      Messages that fit each play

      +
      WideI run ads anyway. This one pays me in the same transaction the buyer's package sells, on a public ledger. Free to join by email: your link
      +
      DepthI need two people who will actually do this with me, not twenty who will look at it. You are one of the two I thought of. your link
      +
      Combination · to a new directThree things today: username, wallet on, one person. I will check in tomorrow.
      +
      + +

      No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. LinkSpin sells advertising; it is not an investment.

      +

      + +
      + +
      +

      LinkSpin: Qualified Start checklist

      +
        +
      1. Username chosen (Profile tab). It is permanent: it becomes your invite link.
      2. +
      3. Main wallet linked (Wallet tab, one free signature).
      4. +
      5. Payouts switched on (Wallet tab, one free transaction).
      6. +
      7. Extra wallet accounts created in your wallet app: IAP Position 2, 3, 4, 5.
      8. +
      9. Each extra account funded with enough POL for a $20 package plus gas.
      10. +
      11. Buy packages: Add a position, tick only the new account, sign once.
      12. +
      13. Buy from: choose the position, Buy $20, confirm in the wallet.
      14. +
      15. Repeat. Two positions open level 2. Five open level 3 and the Nexus badge.
      16. +
      +

      First 30 days, any play

      +

      Day 1

      • Username, wallet linked, payouts on, welcome tour done.
      • Link sent to one person.
      +

      Days 2 to 7

      • One conversation a day.
      • Claim the daily 5 credits.
      • First buyer.
      +

      Days 8 to 14

      • Second qualifying buyer. Level 2 open.
      • Line banner set. First broadcast sent.
      +

      Days 15 to 30

      • Coach the two to their two.
      • Add directs three, four, five. Level 3 open by day 30 is the stretch goal.
      +

      My invite link: __________________________

      +

      No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. LinkSpin sells advertising; it is not an investment.

      +
      + +
      + +
      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/privacy.html b/public/privacy.html new file mode 100644 index 0000000..b185651 --- /dev/null +++ b/public/privacy.html @@ -0,0 +1,34 @@ + + + + +Privacy Policy | LinkSpin + + + + + + +
      +

      Privacy Policy

      Last updated: September 2026

      +
      +

      What we collect

      +

      A minimal set: the email you sign up with, a username and any profile details you choose to add (avatar, bio, social links), and, only if you link one, your public wallet address. Campaign content you create and reports you submit are stored to run the service. We also read on-chain activity that is already public on the blockchain.

      +

      What we do not collect

      +

      We never take custody of your funds or private keys, and we do not sell your personal data.

      +

      How we use it

      +

      To run your account, deliver and measure ads, attribute referrals, send service and notification emails (payouts, messages, onboarding), and keep the Platform secure. You can set your email and chat notification preferences in your dashboard.

      +

      Cookies

      +

      We use a session cookie to keep you signed in and a referral cookie to credit the sponsor whose link you arrived through. That is it. No third-party ad-tracking cookies.

      +

      Sharing

      +

      Your username, public profile, and public wall are visible to others by design, and on-chain transactions are public by nature. We share data with infrastructure providers (hosting, email delivery) only as needed to operate the service, and when required by law.

      +

      Your choices

      +

      You can edit your profile, adjust notification and chat settings, and request account deletion by contacting us. Note that on-chain records cannot be deleted by anyone.

      +

      Security

      +

      We use reasonable safeguards, but no system is perfectly secure. Protect your email and your wallet.

      +
      +
      + + + + diff --git a/public/promo/partner-overview.jpg b/public/promo/partner-overview.jpg new file mode 100644 index 0000000..8e82e2d Binary files /dev/null and b/public/promo/partner-overview.jpg differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..694cfb4 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,6 @@ +User-agent: * +Allow: / +Disallow: /my +Disallow: /admin +Disallow: /api/ +Sitemap: https://linkspin-test.saasy.top/sitemap.xml diff --git a/public/shorts.html b/public/shorts.html new file mode 100644 index 0000000..af49c9b --- /dev/null +++ b/public/shorts.html @@ -0,0 +1,50 @@ + + + + +Shorts | LinkSpin + + + + + + +
      +
      + InstantAdPay · Shorts + + Done +
      +
      +
      Loading your first short…
      + + +
      +
      + + + diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..7eb7f06 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,6 @@ + + + https://instantadpay.com/daily1.0 + https://instantadpay.com/ledgeralways0.8 + https://instantadpay.com/contractmonthly0.7 + diff --git a/public/terms.html b/public/terms.html new file mode 100644 index 0000000..0f56d7d --- /dev/null +++ b/public/terms.html @@ -0,0 +1,42 @@ + + + + +Terms of Service | LinkSpin + + + + + + +
      +

      Terms of Service

      Last updated: September 2026

      +
      +

      Please read these Terms carefully. By creating an account or using LinkSpin ("the Platform"), you agree to them. If you do not agree, do not use the Platform.

      +

      1. What LinkSpin is

      +

      LinkSpin is an advertising platform with a referral program. Members buy advertising packages that mint on-chain ad credits and are delivered as banner, text, video, solo, featured, and verified-visit placements. Payments between members are split by an immutable smart contract and settle directly to members' own wallets. LinkSpin is not a bank, an investment product, a security, or a money-transmission service.

      +

      2. Eligibility

      +

      You must be at least 18 and legally able to enter contracts where you live. You are responsible for complying with the laws of your jurisdiction, including any that restrict crypto activity. You may not use the Platform where doing so is unlawful.

      +

      3. Accounts

      +

      You join free with an email. Keep your access secure; you are responsible for activity under your account. Your wallet is your own. We never hold, custody, or control your funds or private keys.

      +

      4. Purchases and the smart contract

      +

      Package purchases execute on the blockchain. On-chain transactions are final and irreversible. Prices are shown in USD and settled in the network token at the live rate at the moment of purchase. The contract splits each purchase and pays members' wallets in the same transaction; LinkSpin never touches the money. You are responsible for network fees.

      +

      5. Referrals and qualification

      +

      Referral earnings depend on real purchases in your line and on the qualification rules published on the site and enforced by the contract. Nobody earns unless real advertising is bought. We do not promise or guarantee any income.

      +

      6. Advertising rules

      +

      Ads you submit must be lawful and must not be deceptive, adult, hateful, malicious, or infringing. Ads are auto-approved for speed; we may remove or pause any ad or campaign at any time, and members can report ads for review. You are solely responsible for the ads you run and the sites they point to.

      +

      7. Acceptable use

      +

      No fraud, bots, fake traffic, self-dealing to farm rewards, attempts to manipulate the contract, or abuse of other members. We may suspend or terminate accounts that break these rules.

      +

      8. No warranty

      +

      The Platform is provided "as is," without warranties of any kind. Blockchains, wallets, oracles, and third-party sites can fail or behave unexpectedly. We do not guarantee uptime, delivery volumes, or results.

      +

      9. Limitation of liability

      +

      To the fullest extent permitted by law, LinkSpin and its operators are not liable for indirect, incidental, or consequential damages, or for losses arising from crypto volatility, irreversible transactions, third-party sites, or your own decisions.

      +

      10. Changes and termination

      +

      We may update these Terms and the Platform, and continued use means you accept the changes. We may discontinue features. The immutable contract's rules cannot be changed by anyone, including us.

      +

      See also the Disclaimer and Privacy Policy.

      +
      +
      + + + + diff --git a/public/tx.html b/public/tx.html new file mode 100644 index 0000000..f26300b --- /dev/null +++ b/public/tx.html @@ -0,0 +1,40 @@ + + + + +Transaction | LinkSpin + + + + + + +
      +
      +
      +

      One transaction, fully public

      +

      Pulled straight from the chain this site settles on. Every field below is read from the node, + not from our database.

      +
      +
      +

      looking it up… +

      +

      +
      + +
      + +
      + +

      ← Back to the live ledger · Read the contract review

      +
      +
      + + + + diff --git a/public/view.html b/public/view.html new file mode 100644 index 0000000..b94eab4 --- /dev/null +++ b/public/view.html @@ -0,0 +1,50 @@ + + + + + +Viewing ad — LinkSpin + + + + + +
      +
      + InstantAdPay + + Loading the ad… + + + Back to dashboard + + +
      + +
      + + + diff --git a/public/wall.html b/public/wall.html new file mode 100644 index 0000000..2444d25 --- /dev/null +++ b/public/wall.html @@ -0,0 +1,53 @@ + + + + +Banner wall | LinkSpin + + + + + + + +
      +
      +
      +

      InstantAdPay member wall

      +

      Advertise and earn. Paid on-chain, instantly.

      +

      Real ad packages from $5. Every payout arrives as POL on Polygon in your own wallet, in the same transaction the package sells, on a public ledger. Free to join by email, and this member is your sponsor if you join from here.

      +
      + + +
      +

      The line, three levels deep

      +

      Three positions, three levels — the exact levels the contract pays. Every banner here belongs + to a real member of this line, and every payment between them settles on-chain, instantly.

      +
      +
      + + +
      +

      Join this line

      +

      Free to join with just an email. Your wallet only comes out if you buy — + and payments go straight to member wallets, never through an admin.

      + +

      Join free through this wall

      +

      Watch the live ledger · Read the contract

      +
      +
      +
      + + + + diff --git a/public/wallets.html b/public/wallets.html new file mode 100644 index 0000000..ebf3842 --- /dev/null +++ b/public/wallets.html @@ -0,0 +1,127 @@ + + + + +Wallets and buying POL | LinkSpin + + + + + + + + + +
      +
      +

      Member training

      +

      Wallets, and buying POL.

      +

      Which wallet to use, how to set it up in five minutes, and how to buy POL with a debit or credit card through MoonPay so it lands in your own wallet.

      +
      + +

      Members only

      Sign in to your member area to read this guide. Sign in

      + +
      +
      + One coin, one network: POL on Polygon. + Packages are paid in POL and every payout arrives as POL in your own wallet. Nothing else is used. When you buy, choose POL on the Polygon network, never MATIC on Ethereum and never another chain. The site never holds your money: you pay the contract from your wallet and the contract pays your line in the same transaction. +
      + +

      Preferred wallets

      +

      Any wallet that supports the Polygon network works. These are the ones we have tested end to end.

      +
      +
      +

      MetaMask recommended

      +

      Browser extension on desktop, app on phone. The one to use for Qualified Start, because it lets you add extra accounts under the same wallet, one per position. Connects directly on desktop and through WalletConnect on mobile.

      + +
      +
      +

      Phantom

      +

      Clean phone and desktop apps, no purchase-size block. Good choice if you are new and only ever plan to run one position. One setup step: Phantom ships with Polygon switched off. Open Settings, then Active Networks (Developer Settings on some versions), and turn Polygon on before you connect. Until it is on, the connect and buy buttons here will fail or show the wrong network.

      + +
      +
      +

      SafePal

      +

      Phone app with a built-in card purchase option for POL. Connects through WalletConnect. Works well for members who do everything on a phone.

      + +
      +
      +

      Coinbase Wallet

      +

      The self-custody wallet from Coinbase, not the exchange account. Handy if you already buy crypto on Coinbase: buy POL there and send it to this wallet on the Polygon network.

      + +
      +
      +

      Trust Wallet works, with one catch

      +

      Trust Wallet blocks any purchase that would spend most of the POL in the wallet. You see a red "this transaction will drain your wallet" screen with no way past it. If you use Trust, keep about twice the package cost in POL, or buy a smaller package first. The extra POL stays yours. If a blocked attempt leaves the connection dead, open Wallet, tap Disconnect, then Connect again.

      + +
      +
      + +

      Set up a wallet in five minutes

      +
        +
      1. Install it from the official source only. MetaMask: metamask.io. Phantom: phantom.app. SafePal: safepal.io. Coinbase Wallet: wallet.coinbase.com. On a phone, use the app store listing those sites point to.
      2. +
      3. Create a new wallet and set a device password or PIN.
      4. +
      5. Write down the recovery phrase (12 or 24 words) on paper and keep it offline. Anyone who has those words has your money. Nobody from LinkSpin will ever ask for them, and the site never sees them.
      6. +
      7. Make sure Polygon is available. MetaMask asks to switch to Polygon the first time LinkSpin needs it; approve that. SafePal and Coinbase Wallet include Polygon already. Phantom has Polygon built in but switched off by default: Settings, then Active Networks, turn on Polygon.
      8. +
      9. Copy your address. It starts with 0x and is the same on every EVM network. This is where your POL goes and where your payouts arrive.
      10. +
      11. Link it in Members: open the Wallet tab, tap Connect, and sign the free message. Then Switch on payouts, one small transaction that registers your address with the contract. Do both before your people start buying, so their first purchase pays you.
      12. +
      + +

      Buy POL with a card through MoonPay

      +

      MoonPay is a licensed card-to-crypto service. You pay them, they send POL to your wallet. LinkSpin is never in the middle of that payment.

      +

      Opens in a new tab with POL on Polygon selected.

      +
        +
      1. Have your wallet connected first (Wallet tab). Then open Buy packages and look at the live POL price for the package you want. Plan to buy that amount plus 2 or 3 POL for network fees. Trust Wallet users: buy about double.
      2. +
      3. Tap "Buy POL with a card" under the packages. MoonPay opens in a new tab with POL on Polygon selected and your own wallet address filled in. If for any reason it is not filled in, choose POL (Polygon) yourself and paste your address from step 5 above.
      4. +
      5. Enter the amount in dollars or POL and pay with a debit card, credit card, Apple Pay or Google Pay. MoonPay has a minimum order, usually around $30, so the $5 package on its own is below it. Buy enough for the package you actually want.
      6. +
      7. First time only: identity check. MoonPay asks for your email, phone and a photo ID. This is their legal requirement, not ours. It normally takes a few minutes; occasionally a review takes longer, and MoonPay emails you when it clears.
      8. +
      9. Wait for the POL to land. Usually a few minutes. Your wallet balance shows on the Wallet tab and next to your positions.
      10. +
      11. Go back to Buy packages and buy. The wallet asks you to confirm one transaction. The moment it settles, your credits are minted and your line is paid.
      12. +
      + +
      Already own crypto on an exchange? Coinbase, Kraken, Binance and most others sell POL. Withdraw it to your wallet address and pick the Polygon network on the withdrawal screen. Sending on the wrong network can lose the funds.
      +
      Prefer to buy inside the wallet? MetaMask, Trust, SafePal and Phantom all have a Buy button that uses MoonPay or a similar provider. Same rule: POL, Polygon network, your own address.
      + +

      Common questions

      +
      +
      Do I need a wallet to join?

      No. Join with your email. The wallet comes out only when you buy a package or switch on payouts.

      +
      How much POL do I need?

      The Buy packages tab shows the live POL cost of each package. Buy that plus 2 or 3 POL for fees. There is no other cost.

      +
      Why did MoonPay decline my card?

      Some banks block crypto purchases. Try a different card, Apple Pay or Google Pay, or buy on an exchange and withdraw to your wallet on Polygon.

      +
      Can I use the same wallet for more than one position?

      One address is one position on the contract. For Qualified Start, add extra accounts inside MetaMask, each with its own address, and link them on the Buy packages tab. The three Qualified Start videos in Training walk through it.

      +
      Where do my payouts go?

      To the wallet address you switched payouts on with, in the same transaction as the purchase that earned them. Nothing is held on the site.

      +
      + +

      Advertising, not investing. No income is guaranteed; results depend on your effort. Crypto transactions are irreversible and carry risk of loss. MoonPay is an independent, licensed provider; its fees and limits are its own.

      +
      + +
      + +
      Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never share your recovery phrase.
      +
      +
      + + + + diff --git a/qa/earn.mjs b/qa/earn.mjs new file mode 100644 index 0000000..e398576 --- /dev/null +++ b/qa/earn.mjs @@ -0,0 +1,119 @@ +// InstantAdPay QA harness: earning flows, driven end to end on a LOCAL copy. +// node qa/earn.mjs +// Seeds house ads through the admin API (needs ADMIN_PASSWORD of the local server), signs in a fresh +// member, then runs: Watch ads x5 (incl. one wrong captcha pick) + daily claim, Watch videos, Verified +// visits, Inbox solo read + claim. Reports credited amounts and any trip-ups (e.g. a check button +// covered by the overlay's close button). Dwell timers run for real, so allow ~2 minutes. +// Env: LOCAL (default http://127.0.0.1:8797), ADMIN_PASSWORD (default localtest), OUT, PW +// Exit code 1 when a flow that had inventory failed to credit. +import { pathToFileURL } from 'node:url'; +import fs from 'node:fs'; +const PW = process.env.PW || 'D:/Projects/MarketingAgent/qa-tester/node_modules/playwright'; +const { chromium } = (await import(pathToFileURL(PW + '/index.js').href)).default; +const B = process.env.LOCAL || 'http://127.0.0.1:8797'; +const OUT = process.env.OUT || 'qa/out'; +const ADMIN = process.env.ADMIN_PASSWORD || 'localtest'; +fs.mkdirSync(OUT, { recursive: true }); +const CAP = { rocket: '🚀', 'lightning bolt': '⚡', key: '🔑', target: '🎯', wave: '🌊', flame: '🔥', diamond: '💎', magnet: '🧲', bell: '🔔', moon: '🌙' }; +const lines = []; const log = (...a) => { const s = a.join(' '); console.log(s); lines.push(s); }; +const problems = []; +const browser = await chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1280, height: 950 } }); +const page = await ctx.newPage(); +page.on('dialog', d => d.accept()); +const api = async (p, body) => (await page.request.fetch(B + p, body ? { method: 'POST', data: body } : {})).json(); + +// seeds: targets must be public and frameable (rmcircle.team sends frame-ancestors *) +const seeds = [ + { type: 'text', name: 'QA text 1', targetUrl: 'https://rmcircle.team/', title: 'Text one', body: 'Body one', budget: 1000 }, + { type: 'text', name: 'QA text 2', targetUrl: 'https://rmcircle.team/how-pay-works', title: 'Text two', body: 'Body two', budget: 1000 }, + { type: 'banner', name: 'QA banner', targetUrl: 'https://rmcircle.team/start', imageUrl: 'https://rmcircle.team/banners/rmc-728x90-v1.png', size: '728x90', budget: 1000 }, + { type: 'video', name: 'QA video', targetUrl: 'https://rmcircle.team/', videoUrl: process.env.QA_VIDEO_URL || 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm', videoW: 960, videoH: 540, watchSecs: 10, title: 'QA clip', budget: 1000 }, + { type: 'visits', name: 'QA visits', targetUrl: 'https://rmcircle.team/contract', title: 'Visit the contract page', count: 20 }, + { type: 'solo', name: 'QA solo', targetUrl: 'https://rmcircle.team/contract', title: 'QA solo subject line', body: '

      This is a QA solo ad body with enough characters to pass validation for the inbox test run.

      ', ctaLabel: 'See it', budget: 100 } +]; +for (const sd of seeds) { + const r = await (await page.request.post(B + '/api/admin/campaigns', { headers: { Authorization: 'Bearer ' + ADMIN }, data: sd })).json(); + log('seed', sd.type, r.ok ? '#' + r.campaign.id + ' ' + r.campaign.status : 'FAIL ' + r.error); + if (!r.ok) problems.push('seed ' + sd.type + ': ' + r.error); +} +await page.goto(B + '/my', { waitUntil: 'networkidle' }); +await page.fill('#mcEmail', 'qa-earn@example.com'); await page.click('#mcSendBtn'); await page.waitForSelector('#mcVerifyBtn:not([hidden])'); await page.click('#mcVerifyBtn'); await page.waitForTimeout(2000); +if (await page.$('#onboardModal:not([hidden])')) { await page.fill('#obUsername', 'qaearner'); await page.click('#obSave'); await page.waitForTimeout(1000); } +await page.evaluate(() => { document.querySelectorAll('.modal-back,.lgate').forEach(m => m.hidden = true); }); +const start = await api('/api/my/earn'); log('start:', JSON.stringify(start)); + +// Watch ads +await page.click('.bo-menu [data-pane="earn"]'); await page.waitForTimeout(800); +await page.click('.subtabs [data-earn="watch"]'); await page.waitForTimeout(500); +let credited = 0; +for (let i = 0; i < 5; i++) { + await page.click('#earnStartBtn'); await page.waitForTimeout(1200); + const fr = page.frames().find(f => f.url().includes('/view/')); + if (!fr) { log('AD ' + (i + 1) + ': viewer did not open; box says:', (await page.textContent('#earnAdBox')).trim()); problems.push('watch: viewer did not open'); break; } + const t0 = Date.now(); + try { await fr.waitForSelector('#vCheck.on', { timeout: 30000 }); } catch (e) { log('AD ' + (i + 1) + ': check never appeared:', await fr.textContent('#vMsg')); problems.push('watch: check never appeared'); break; } + const secs = ((Date.now() - t0) / 1000).toFixed(1); + if (i === 1) { // trip-up: wrong pick first + const name = ((await fr.textContent('#vPrompt')).match(/Click the (.+):/) || [])[1]; + for (const o of await fr.$$('#vOpts button')) { if ((await o.textContent()) !== CAP[name]) { await o.click(); break; } } + await fr.waitForTimeout(700); log(' wrong pick handled:', (await fr.textContent('#vMsg')).trim()); + } + const name2 = ((await fr.textContent('#vPrompt')).match(/Click the (.+):/) || [])[1]; + const hit = await fr.evaluate(want => { const b = [...document.querySelectorAll('#vOpts button')].find(x => x.textContent === want); if (!b) return null; const r = b.getBoundingClientRect(); const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); const covered = !!(top && top !== b && !b.contains(top)); b.click(); return { covered, by: covered ? top.tagName + '#' + top.id : '' }; }, CAP[name2]); + if (hit && hit.covered) { log(' TRIP-UP: correct answer button covered by', hit.by); problems.push('watch: answer button covered by ' + hit.by); } + await fr.waitForTimeout(900); + const timer = await fr.textContent('#vTimer'); if (/credited/.test(timer)) credited++; + log('AD ' + (i + 1) + ': check after ' + secs + 's | ' + timer + ' | ' + (await fr.textContent('#vMsg')).trim()); + await page.evaluate(() => { const o = document.getElementById('adOverlay'); if (o) o.querySelector('button').click(); }); await page.waitForTimeout(700); +} +await page.waitForTimeout(800); +log('after set:', await page.textContent('#earnProgress'), '| claim visible:', !!(await page.$('#earnClaimBtn:not([hidden])'))); +if (await page.$('#earnStartBtn:not([hidden])')) { await page.click('#earnStartBtn'); await page.waitForTimeout(900); log('view-after-complete says:', (await page.textContent('#earnAdBox')).trim()); } +else log('view button hidden after the set (done screen with claim), as designed since 2026-09-12'); +if (await page.$('#earnClaimBtn:not([hidden])')) { await page.click('#earnClaimBtn'); await page.waitForTimeout(1000); log('claimed; balance:', await page.textContent('#earnBalance')); } +else if (credited === 5) problems.push('watch: 5 views credited but claim button not shown'); + +// Watch videos +await page.click('.subtabs [data-earn="videos"]'); await page.waitForTimeout(800); +await page.click('#vidStartBtn'); await page.waitForTimeout(2500); +const v1 = await page.evaluate(() => { const p = document.getElementById('vidPlayer'); return { paused: p.paused, t: p.currentTime, timer: document.getElementById('vidTimer').textContent }; }); +log('video after 2.5s:', JSON.stringify(v1)); +await page.waitForTimeout(23000); +const v2 = await page.evaluate(() => { const p = document.getElementById('vidPlayer'); return { t: p.currentTime, timer: document.getElementById('vidTimer').textContent, progress: document.getElementById('vidProgress').textContent }; }); +log('video after 25s:', JSON.stringify(v2), v2.t < 10 ? '(clip stalled in headless; verify on live with a real video)' : ''); + +// Verified visits +await page.click('.subtabs [data-earn="visits"]'); await page.waitForTimeout(800); +await page.click('#vsStartBtn'); await page.waitForTimeout(800); +log('visit loaded:', (await page.textContent('#vsBox')).trim().slice(0, 80)); +let popup = null; +if (await page.$('#vsVisit:not([hidden])')) { + [popup] = await Promise.all([ctx.waitForEvent('page', { timeout: 5000 }).catch(() => null), page.click('#vsVisit')]); + await page.waitForTimeout(10500); + const vp = (await page.textContent('#vsPrompt')) || ''; const vname = (vp.match(/Click the (.+):/) || [])[1]; + if (vname) { for (const b of await page.$$('#vsOpts button')) { if ((await b.textContent()) === CAP[vname]) { await b.click(); break; } } await page.waitForTimeout(900); } + const hint = (await page.textContent('#vsHint')).trim(); log('visit result:', hint, '|', await page.textContent('#vsProgress')); + if (!/credit/.test(hint)) problems.push('visits: not credited: ' + hint); + if (popup) await popup.close(); +} else { log('visits: nothing served'); problems.push('visits: nothing served'); } + +// Inbox +await page.click('.subtabs [data-earn="inbox"]'); await page.waitForTimeout(1200); +const rows = await page.$$('#ibList .ib-row'); log('inbox rows:', rows.length); +if (rows.length) { + await rows[0].click(); await page.waitForTimeout(900); + const [p2] = await Promise.all([ctx.waitForEvent('page', { timeout: 5000 }).catch(() => null), page.click('#ibVisit')]); if (p2) await p2.close(); + await page.bringToFront(); await page.waitForTimeout(12000); + const dis = await page.$eval('#ibClaimBtn', b => b.disabled); log('after 12s: claim btn =', await page.textContent('#ibClaimBtn'), '| disabled =', dis); + if (!dis) { await page.click('#ibClaimBtn'); await page.waitForTimeout(900); log('inbox claim:', (await page.textContent('#ibHint')).trim()); } + else problems.push('inbox: claim still disabled after dwell'); +} else problems.push('inbox: no solo delivered'); + +const fin = await api('/api/my/earn'); log('FINAL:', JSON.stringify(fin)); +await page.screenshot({ path: OUT + '/earn-final.png' }).catch(() => {}); +await browser.close(); +log('===== EARN FLOWS ' + new Date().toISOString() + ' ====='); +log(problems.length ? 'PROBLEMS: ' + problems.join(' | ') : 'ALL EARN FLOWS OK (video needs a real clip on live)'); +fs.writeFileSync(OUT + '/earn-report.txt', lines.join('\n') + '\n'); +process.exit(problems.length ? 1 : 0); diff --git a/qa/run.sh b/qa/run.sh new file mode 100644 index 0000000..7b8ed68 --- /dev/null +++ b/qa/run.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# InstantAdPay QA harness runner. From the site dir: +# bash qa/run.sh public # live public pages only (no local server) +# bash qa/run.sh member # local copy: member + admin walk +# bash qa/run.sh earn # local copy: earning flows end to end (~2-3 min) +# bash qa/run.sh all # public + member + earn +# Each local run boots server.js on its own port with a throwaway data dir (JSON store, no DB, no +# mail key, devCode sign-in), so nothing touches production data. Reports land in qa/out/. +set -u +cd "$(dirname "$0")/.." +MODE="${1:-all}" +OUT="qa/out"; mkdir -p "$OUT" +TMP="${TMPDIR:-${TEMP:-/tmp}}/iap-qa-$$" +status=0 + +start_local() { # $1 = port + local port="$1" + rm -rf "$TMP-$port"; mkdir -p "$TMP-$port/uploads" + (PORT="$port" DATA_DIR="$TMP-$port" ADMIN_EMAIL="${ADMIN_EMAIL:-martybostick@gmail.com}" ADMIN_PASSWORD=localtest node server.js > "$OUT/server-$port.log" 2>&1 &) + for i in $(seq 1 20); do curl -s -m 2 "http://127.0.0.1:$port/api/config" >/dev/null && return 0; sleep 1; done + echo "local server on :$port did not come up"; return 1 +} +stop_local() { # $1 = port + for p in $(netstat -ano 2>/dev/null | grep ":$1 " | awk '{print $5}' | sort -u); do taskkill //F //PID "$p" >/dev/null 2>&1; done + rm -rf "$TMP-$1" +} + +if [ "$MODE" = "public" ]; then + node qa/walk.mjs public || status=1 +elif [ "$MODE" = "member" ]; then + start_local 8796 && { LOCAL=http://127.0.0.1:8796 node qa/walk.mjs member || status=1; }; stop_local 8796 +elif [ "$MODE" = "earn" ]; then + start_local 8797 && { LOCAL=http://127.0.0.1:8797 node qa/earn.mjs || status=1; }; stop_local 8797 +else + node qa/walk.mjs public || status=1 + start_local 8796 && { LOCAL=http://127.0.0.1:8796 node qa/walk.mjs member || status=1; }; stop_local 8796 + start_local 8797 && { LOCAL=http://127.0.0.1:8797 node qa/earn.mjs || status=1; }; stop_local 8797 +fi +echo +echo "reports: $OUT/walk-report.txt $OUT/earn-report.txt (screenshots alongside)" +exit $status diff --git a/qa/walk.mjs b/qa/walk.mjs new file mode 100644 index 0000000..76f750d --- /dev/null +++ b/qa/walk.mjs @@ -0,0 +1,147 @@ +// InstantAdPay QA harness: site walk. +// node qa/walk.mjs public -> live public pages (no sign-in): errors, failed requests, broken images, dead links, mobile +// node qa/walk.mjs member -> local copy: sign in, every member pane + sub-tab, every admin pane, forms +// node qa/walk.mjs all -> both +// Env: LIVE (default https://instantadpay.com), LOCAL (default http://127.0.0.1:8796), OUT (report dir), +// PW (playwright package dir; default D:/Projects/MarketingAgent/qa-tester/node_modules/playwright) +// Exit code 1 when any [bug] finding remains after noise filtering. +import { pathToFileURL } from 'node:url'; +import fs from 'node:fs'; +const PW = process.env.PW || 'D:/Projects/MarketingAgent/qa-tester/node_modules/playwright'; +const { chromium } = (await import(pathToFileURL(PW + '/index.js').href)).default; +const MODE = process.argv[2] || 'all'; +const LIVE = process.env.LIVE || 'https://instantadpay.com'; +const LOCAL = process.env.LOCAL || 'http://127.0.0.1:8796'; +const OUT = process.env.OUT || 'qa/out'; +fs.mkdirSync(OUT, { recursive: true }); +const findings = []; +const note = (sev, where, what) => findings.push({ sev, where, what }); +const NOISE = /walletconnect|reown|web3modal|coingecko|fonts\.|\/api\/feed\/live|\/api\/auth\/logout/; + +function watch(page, base) { + const bag = { console: [], failed: [], status: [] }; + page.on('pageerror', e => bag.console.push('pageerror: ' + e.message)); + page.on('console', m => { + if (m.type() !== 'error') return; + const loc = (m.location() && m.location().url) || ''; + if (loc && !loc.startsWith(base)) return; // third-party or framed page, not ours + if (/status of (400|401|404)/.test(m.text())) return; // expected API answers surface as console noise + bag.console.push(m.text()); + }); + page.on('requestfailed', r => { const u = r.url(); if (u.startsWith(base) && !NOISE.test(u)) bag.failed.push(u + ' ' + (r.failure() && r.failure().errorText)); }); + page.on('response', r => { const st = r.status(); const u = r.url(); if (st >= 500 && u.startsWith(base)) bag.status.push(st + ' ' + u); }); + return bag; +} +function flush(bag, label) { + for (const c of bag.console) note('bug', label, 'console: ' + c.slice(0, 200)); + for (const f of bag.failed) note('bug', label, 'request failed: ' + f.slice(0, 200)); + for (const s of bag.status) note('bug', label, 'HTTP ' + s.slice(0, 200)); + bag.console.length = bag.failed.length = bag.status.length = 0; +} +async function domChecks(page, label) { + const r = await page.evaluate(() => { + const vis = el => el.offsetParent !== null; + const brokenImgs = [...document.images].filter(i => i.complete && i.naturalWidth === 0 && i.src && vis(i)).map(i => i.src); + const unfilled = [...document.querySelectorAll('body *')].filter(el => el.children.length === 0 && (el.textContent || '').trim() === '…' && vis(el)).length; + const overflow = document.documentElement.scrollWidth > document.documentElement.clientWidth + 2; + return { brokenImgs, unfilled, overflow, title: document.title }; + }); + if (r.brokenImgs.length) note('bug', label, 'broken images: ' + r.brokenImgs.slice(0, 3).join(', ')); + if (r.unfilled) note('warn', label, r.unfilled + ' element(s) still showing the loading ellipsis'); + if (r.overflow) note('warn', label, 'page scrolls horizontally'); + return r; +} +const hide = page => page.evaluate(() => { document.querySelectorAll('.modal-back,.lgate').forEach(m => m.hidden = true); }); + +const browser = await chromium.launch(); + +if (MODE === 'public' || MODE === 'all') { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await ctx.newPage(); const bag = watch(page, LIVE); + const PUBLIC = ['/', '/ledger', '/contract', '/terms', '/privacy', '/disclaimer', '/wall/martbost', '/join/martbost', + '/join/martbost?v=instant', '/join/martbost?v=adspend', '/join/martbost?v=free', '/join/martbost?v=ledger', '/join/martbost?v=two', + '/admin', '/my', '/shorts', '/nope-404']; + for (const p of PUBLIC) { + const label = 'LIVE ' + p; + try { + const resp = await page.goto(LIVE + p, { waitUntil: 'domcontentloaded', timeout: 45000 }); + await page.waitForTimeout(2500); + const st = resp ? resp.status() : 0; + if (p === '/nope-404') { if (st !== 404) note('warn', label, 'expected 404, got ' + st); } + else if (st >= 400) note('bug', label, 'page HTTP ' + st); + const d = await domChecks(page, label); + const hrefs = await page.evaluate(() => [...new Set([...document.querySelectorAll('a[href]')].map(a => a.href).filter(h => h.startsWith(location.origin) && !h.includes('#') && !h.includes('/api/')))]); + for (const h of hrefs.slice(0, 40)) { + try { const r = await page.request.head(h, { timeout: 15000 }); if (r.status() >= 400) note('bug', label, 'dead link ' + h + ' -> ' + r.status()); } + catch (e) { note('warn', label, 'link check failed ' + h); } + } + flush(bag, label); console.log('ok', label, '|', d.title); + } catch (e) { note('bug', label, 'navigation failed: ' + e.message.slice(0, 160)); flush(bag, label); } + } + const m = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true }); + const mp = await m.newPage(); const mbag = watch(mp, LIVE); + for (const p of ['/', '/join/martbost?v=instant', '/wall/martbost', '/my']) { + await mp.goto(LIVE + p, { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(e => note('bug', 'LIVE mobile ' + p, e.message)); + await mp.waitForTimeout(2000); await domChecks(mp, 'LIVE mobile ' + p); flush(mbag, 'LIVE mobile ' + p); + await mp.screenshot({ path: OUT + '/mobile' + p.replace(/[^a-z0-9]+/gi, '-') + '.png' }).catch(() => {}); + } + await ctx.close(); await m.close(); +} + +if (MODE === 'member' || MODE === 'all') { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 950 } }); + const page = await ctx.newPage(); const bag = watch(page, LOCAL); + page.on('dialog', d => d.accept()); + const L = 'LOCAL '; + await page.goto(LOCAL + '/my', { waitUntil: 'networkidle' }); + await page.fill('#mcEmail', 'qa-walk@example.com'); await page.click('#mcSendBtn'); + await page.waitForSelector('#mcVerifyBtn:not([hidden])'); await page.click('#mcVerifyBtn'); await page.waitForTimeout(2000); + if (await page.$('#onboardModal:not([hidden])')) { await page.fill('#obUsername', 'qawalker'); await page.click('#obSave'); await page.waitForTimeout(1200); } + await hide(page); flush(bag, L + 'sign-in'); + const PANES = ['overview', 'line', 'pipeline', 'buy', 'campaigns', 'earn', 'earnings', 'promo', 'training', 'wallet', 'profile']; + for (const pn of PANES) { + const label = L + 'my#' + pn; + await page.click('.bo-menu [data-pane="' + pn + '"]'); await page.waitForTimeout(1200); await hide(page); + const vis = await page.evaluate(id => { const el = document.getElementById('pane-' + id); return el && !el.hidden && el.offsetHeight > 40; }, pn); + if (!vis) note('bug', label, 'pane did not render'); + await domChecks(page, label); + const subs = await page.$$('#pane-' + pn + ' .subtabs [data-earn], #pane-' + pn + ' .promo-pills [data-promo]'); + for (const s of subs) { try { await s.click(); await page.waitForTimeout(500); } catch (e) {} } + if (subs.length) await domChecks(page, label + ' (sub-tabs)'); + flush(bag, label); + await page.screenshot({ path: OUT + '/my-' + pn + '.png' }).catch(() => {}); + console.log('ok', label, 'subtabs:', subs.length); + } + await page.click('.bo-menu [data-pane="promo"]'); await page.waitForTimeout(800); + if (!(await page.$$('#promoPosts .promo-block')).length) note('bug', L + 'promo', 'no post cards rendered'); + const chat = await page.$('#chatMenuBtn'); if (chat) { await chat.click(); await page.waitForTimeout(800); await domChecks(page, L + 'messages'); flush(bag, L + 'messages'); } + const lo = await page.$('#logoutLink'); if (lo) { await lo.click(); await page.waitForTimeout(1000); } + if (!(await page.$('#authArea:not([hidden])'))) note('bug', L + 'logout', 'auth card not shown after log out'); + flush(bag, L + 'logout'); + // admin + await page.goto(LOCAL + '/admin', { waitUntil: 'networkidle' }); + await page.fill('#adEmail', process.env.ADMIN_EMAIL || 'martybostick@gmail.com'); await page.click('#adSend'); await page.waitForSelector('#adVerify:not([hidden])'); await page.click('#adVerify'); await page.waitForTimeout(1200); + for (const pn of ['overview', 'house', 'campaigns', 'members', 'reports', 'traffic', 'blog', 'releases', 'pnl', 'settings']) { + const label = L + 'admin#' + pn; + await page.click('.bo-menu [data-pane="' + pn + '"]'); await page.waitForTimeout(1200); + const vis = await page.evaluate(id => { const el = document.getElementById('pane-' + id); return el && !el.hidden && el.offsetHeight > 40; }, pn); + if (!vis) note('bug', label, 'pane did not render'); + await domChecks(page, label); flush(bag, label); + await page.screenshot({ path: OUT + '/admin-' + pn + '.png' }).catch(() => {}); + console.log('ok', label); + } + await page.click('.bo-menu [data-pane="house"]'); await page.waitForTimeout(500); + for (const t of ['banner', 'text', 'login', 'solo', 'video', 'featured', 'visits']) { await page.selectOption('#hType', t); await page.waitForTimeout(120); } + await page.selectOption('#hType', 'text'); await page.click('#hCreate'); await page.waitForTimeout(800); + if (!(await page.$('#hErr:not([hidden])'))) note('warn', L + 'admin house form', 'empty submit showed no validation message'); + flush(bag, L + 'admin house form'); + await ctx.close(); +} +await browser.close(); + +const bugs = findings.filter(f => f.sev === 'bug'), warns = findings.filter(f => f.sev === 'warn'); +const lines = ['===== QA WALK (' + MODE + ') ' + new Date().toISOString() + ' =====', 'bugs: ' + bugs.length + ' | warnings: ' + warns.length, + ...findings.map(f => '[' + f.sev + '] ' + f.where + ' :: ' + f.what)]; +console.log('\n' + lines.join('\n')); +fs.writeFileSync(OUT + '/walk-report.txt', lines.join('\n') + '\n'); +process.exit(bugs.length ? 1 : 0); diff --git a/registry.js b/registry.js new file mode 100644 index 0000000..cf0b9e5 --- /dev/null +++ b/registry.js @@ -0,0 +1,55 @@ +// Network registry (LinkSpin, 2026-09-15): who is who across the Crypto Team Build Network +// properties, keyed by email (and by main wallet once one exists). InstantAdPay writes it as +// members join, name themselves, link a wallet and bind a sponsor; LinkSpin reads it at sign-in +// and wallet link so a member's sponsor line carries over. Nothing here moves money: each +// contract still binds its own sponsor at first activation. Dual-mode store like the rest of +// the engine (MySQL when NETWORK_DB_URL is set, JSON on the volume otherwise). In the test area +// the JSON file is seeded from an export of the live InstantAdPay accounts. +const fs = require('fs'); +const path = require('path'); + +let DATA_DIR = null; +const norm = e => String(e || '').trim().toLowerCase(); + +const J = { + db: null, + FILE: () => path.join(DATA_DIR, 'registry.json'), + load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = { v: 1, byEmail: {} }; } if (!this.db.byEmail) this.db.byEmail = {}; }, + save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }, + async get(email) { if (!this.db) this.load(); return this.db.byEmail[norm(email)] || null; }, + async byWallet(addr) { if (!this.db) this.load(); const a = norm(addr); return Object.values(this.db.byEmail).find(r => norm(r.wallet) === a) || null; }, + async byUsername(u) { if (!this.db) this.load(); const x = norm(u); return Object.values(this.db.byEmail).find(r => norm(r.username) === x) || null; }, + async upsert(rec) { + if (!this.db) this.load(); + const e = norm(rec.email); if (!e) return null; + const cur = this.db.byEmail[e] || { email: e, created: Date.now() }; + for (const k of ['username', 'wallet', 'sponsorEmail', 'firstProperty', 'joinedAt']) if (rec[k] != null && rec[k] !== '') cur[k] = k === 'wallet' || k === 'sponsorEmail' ? norm(rec[k]) : rec[k]; + cur.updated = Date.now(); this.db.byEmail[e] = cur; this.save(); return cur; + }, + async all() { if (!this.db) this.load(); return Object.values(this.db.byEmail); }, + async count() { if (!this.db) this.load(); return Object.keys(this.db.byEmail).length; } +}; + +function init(opts) { DATA_DIR = opts.dataDir; } +async function get(email) { return J.get(email); } +async function byWallet(addr) { return J.byWallet(addr); } +async function byUsername(u) { return J.byUsername(u); } +async function upsert(rec) { return J.upsert(rec); } +async function all() { return J.all(); } +async function count() { return J.count(); } +// the sponsor chain above an email, nearest first, bounded (a loop in bad data must not hang a request) +async function chainUp(email, max = 25) { + const out = []; let cur = await get(email); const seen = new Set([norm(email)]); + while (cur && cur.sponsorEmail && out.length < max) { + const e = norm(cur.sponsorEmail); if (seen.has(e)) break; seen.add(e); + const sp = await get(e); if (!sp) break; + out.push(sp); cur = sp; + } + return out; +} +// bulk load from an InstantAdPay export: [{email, username, wallet, sponsorEmail, joinedAt}] +async function importRows(rows, property) { + let n = 0; for (const r of rows || []) { if (await upsert(Object.assign({ firstProperty: property || 'instantadpay' }, r))) n++; } return n; +} + +module.exports = { init, get, byWallet, byUsername, upsert, all, count, chainUp, importRows }; diff --git a/releases.js b/releases.js new file mode 100644 index 0000000..c14937a --- /dev/null +++ b/releases.js @@ -0,0 +1,70 @@ +// Release notes + roadmap (Marty, 2026-09-14): what shipped and what is coming, written in Admin > Releases, +// shown on the public /whats-new page and in a "What's new" card on every member's Overview. +// Storage: DATA_DIR/releases.json { notes: [{id, date, title, body, tags[]}], roadmap: [{id, title, note, status, eta}] } +const fs = require('fs'); +const path = require('path'); +let FILE = null; +const SITE = 'https://linkspin-test.saasy.top'; +const TAGS = ['new', 'improved', 'fixed']; +const STATUSES = ['planned', 'building', 'done']; +const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + +function init(opts) { FILE = path.join(opts.dataDir, 'releases.json'); } +function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (e) { return { notes: [], roadmap: [] }; } } +function save(d) { fs.writeFileSync(FILE, JSON.stringify(d, null, 1)); } +const newId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + +// body text: plain lines; a line starting with "- " becomes a bullet; blank line = paragraph break +function bodyHtml(text) { + const lines = String(text || '').split(/\r?\n/); + let out = '', list = false, para = []; + const flush = () => { if (para.length) { out += '

      ' + esc(para.join(' ')) + '

      '; para = []; } }; + for (const l of lines) { + if (/^\s*-\s+/.test(l)) { flush(); if (!list) { out += '
        '; list = true; } out += '
      • ' + esc(l.replace(/^\s*-\s+/, '')) + '
      • '; continue; } + if (list) { out += '
      '; list = false; } + if (!l.trim()) { flush(); continue; } + para.push(l.trim()); + } + if (list) out += ''; flush(); + return out; +} + +function notes() { return load().notes.slice().sort((a, b) => (b.date || '').localeCompare(a.date || '') || (b.ts || 0) - (a.ts || 0)); } +function roadmap() { const order = { building: 0, planned: 1, done: 2 }; return load().roadmap.slice().sort((a, b) => (order[a.status] - order[b.status]) || (a.order || 0) - (b.order || 0)); } +function saveNote(input) { + const d = load(); const title = String(input.title || '').trim().slice(0, 120); if (!title) return { error: 'Give the note a title.' }; + const date = /^\d{4}-\d{2}-\d{2}$/.test(String(input.date || '')) ? input.date : new Date().toISOString().slice(0, 10); + const tags = String(input.tags || '').split(',').map(t => t.trim().toLowerCase()).filter(t => TAGS.includes(t)); + const n = { id: input.id && d.notes.find(x => x.id === input.id) ? input.id : newId(), date, title, body: String(input.body || '').slice(0, 4000), tags: tags.length ? tags : ['new'], ts: Date.now() }; + const i = d.notes.findIndex(x => x.id === n.id); if (i >= 0) d.notes[i] = Object.assign({}, d.notes[i], n); else d.notes.push(n); + save(d); return { ok: true, note: n }; +} +function saveRoadmap(input) { + const d = load(); const title = String(input.title || '').trim().slice(0, 120); if (!title) return { error: 'Give the item a title.' }; + const status = STATUSES.includes(input.status) ? input.status : 'planned'; + const r = { id: input.id && d.roadmap.find(x => x.id === input.id) ? input.id : newId(), title, note: String(input.note || '').slice(0, 600), status, eta: String(input.eta || '').slice(0, 40), order: Number(input.order) || d.roadmap.length + 1, ts: Date.now() }; + const i = d.roadmap.findIndex(x => x.id === r.id); if (i >= 0) d.roadmap[i] = Object.assign({}, d.roadmap[i], r); else d.roadmap.push(r); + save(d); return { ok: true, item: r }; +} +function remove(kind, id) { const d = load(); const k = kind === 'roadmap' ? 'roadmap' : 'notes'; d[k] = d[k].filter(x => x.id !== id); save(d); return { ok: true }; } +function publicView() { return { notes: notes().slice(0, 30), roadmap: roadmap().filter(r => r.status !== 'done').slice(0, 12), latest: (notes()[0] || {}).date || null }; } + +function fmt(d) { try { return new Date(d + 'T12:00:00Z').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); } catch (e) { return d; } } +const tagChip = t => '' + t + ''; +function renderPage() { + const v = publicView(); + const desc = 'What changed on LinkSpin and what is coming next: release notes and the roadmap, updated as things ship.'; + let h = 'What\'s new | LinkSpin' + + '' + + '' + + '
      ' + + '

      Release notes and roadmap

      What\'s new, and what\'s next.

      ' + esc(desc) + '

      '; + h += '

      On the roadmap

      '; + h += v.roadmap.length ? '
      ' + v.roadmap.map(r => '
      ' + r.status + (r.eta ? ' · ' + esc(r.eta) : '') + '
      ' + esc(r.title) + '' + (r.note ? '' + esc(r.note) + '' : '') + '
      ').join('') + '
      ' : '

      Nothing queued right now.

      '; + h += '

      Release notes

      '; + h += v.notes.length ? v.notes.map(n => '

      ' + esc(n.title) + n.tags.map(tagChip).join('') + '

      ' + fmt(n.date) + '

      ' + bodyHtml(n.body) + '
      ').join('') : '

      No notes yet.

      '; + h += '

      Suggestions and bug reports: the chat bubble on any page, or the LinkSpin Telegram group.

      '; + h += '
      '; + return h; +} +module.exports = { init, notes, roadmap, saveNote, saveRoadmap, remove, publicView, renderPage, bodyHtml, TAGS, STATUSES }; diff --git a/reports.js b/reports.js new file mode 100644 index 0000000..6db4c57 --- /dev/null +++ b/reports.js @@ -0,0 +1,58 @@ +// Ad reports: members flag a broken or inappropriate ad (ads are auto-approved, +// so this is the safety valve). Stored dual-mode (MySQL when DATABASE_URL, else +// a JSON file in the volume) and surfaced to the admin. +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, 'ad-reports.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 REASONS = ['broken', 'inappropriate', 'spam', 'scam', 'other']; + +async function add(campaignId, reporterEmail, reason, note) { + const now = Date.now(); + const r = REASONS.includes(String(reason)) ? String(reason) : 'other'; + const n = String(note || '').slice(0, 500); + const cid = Number(campaignId) || 0; + const who = String(reporterEmail || '').toLowerCase(); + if (db.enabled()) { + const ins = await db.q('INSERT INTO ad_reports (campaign_id,reporter,reason,note,ts) VALUES (?,?,?,?,?)', + [cid, who, r, n, now]); + return { id: ins.insertId, campaignId: cid, reason: r }; + } + if (!J.db) J.load(); + const id = J.db.nextId++; + J.db.items.push({ id, campaignId: cid, reporter: who, reason: r, note: n, ts: now, resolved: false }); + J.save(); + return { id, campaignId: cid, reason: r }; +} +// recent reports for the admin view (newest first) +async function list(limit = 200) { + if (db.enabled()) { + const rows = await db.q('SELECT id,campaign_id,reporter,reason,note,ts,resolved FROM ad_reports ORDER BY ts DESC LIMIT ?', [limit]); + return rows.map(x => ({ id: x.id, campaignId: x.campaign_id, reporter: x.reporter, reason: x.reason, note: x.note, ts: Number(x.ts), resolved: !!x.resolved })); + } + if (!J.db) J.load(); + return J.db.items.slice().sort((a, b) => b.ts - a.ts).slice(0, limit); +} +async function resolve(id) { + if (db.enabled()) { await db.q('UPDATE ad_reports SET resolved=1 WHERE id=?', [Number(id)]); return { ok: true }; } + if (!J.db) J.load(); + const it = J.db.items.find(x => x.id === Number(id)); if (it) { it.resolved = true; J.save(); } + return { ok: true }; +} +// how many unresolved (for an admin badge) +async function openCount() { + if (db.enabled()) { const r = await db.q('SELECT COUNT(*) n FROM ad_reports WHERE resolved=0'); return r[0].n; } + if (!J.db) J.load(); + return J.db.items.filter(x => !x.resolved).length; +} + +module.exports = { init, add, list, resolve, openCount, REASONS }; diff --git a/rotator.js b/rotator.js new file mode 100644 index 0000000..8c9796e --- /dev/null +++ b/rotator.js @@ -0,0 +1,104 @@ +// The rotator (LinkSpin's core tool, 2026-09-15): a member owns rotations; each rotation has a short +// code and a list of destinations with weights. /r/ picks one by weight, records the hit +// (day, country, source, device, a daily-salted visitor hash so uniques are honest, bot filter) +// and redirects in one hop. Destinations can be paused; a fallback catches the empty case. +// Health checks (a dead or parked destination is paused automatically) come in a later pass. +// JSON store on the volume for the test area; the MySQL twin follows the engine's dual-mode pattern. +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +let DATA_DIR = null, geo = null; +const DAY = 86400000; +const J = { + db: null, + FILE: () => path.join(DATA_DIR, 'rotator.json'), + load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = { v: 1, rotations: [], hits: [] }; } }, + save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} } +}; +function init(opts) { DATA_DIR = opts.dataDir; geo = opts.geo || null; J.load(); } +const norm = e => String(e || '').trim().toLowerCase(); +const newCode = () => { const a = 'abcdefghjkmnpqrstuvwxyz23456789'; let s = ''; for (let i = 0; i < 6; i++) s += a[crypto.randomInt(a.length)]; return s; }; +const BOT = /bot|crawl|spider|slurp|facebookexternalhit|preview|telegrambot|whatsapp|curl|wget|python-requests|headless/i; +const cleanUrl = u => { const s = String(u || '').trim(); return /^https?:\/\/[^\s]+$/i.test(s) && s.length <= 500 ? s : null; }; + +function pub(r) { + const hits = J.db.hits.filter(h => h.r === r.id); + const since7 = Date.now() - 7 * DAY; + const perDest = {}; + for (const h of hits) { const d = perDest[h.d] = perDest[h.d] || { hits: 0, hits7: 0, uniques: new Set() }; d.hits++; if (h.ts >= since7) d.hits7++; d.uniques.add(h.v); } + return { id: r.id, code: r.code, name: r.name, owner: r.owner, created: r.created, fallback: r.fallback || '', paused: !!r.paused, + destinations: r.destinations.map(d => Object.assign({}, d, { hits: (perDest[d.id] || { hits: 0 }).hits, hits7: (perDest[d.id] || { hits7: 0 }).hits7, uniques: (perDest[d.id] || { uniques: new Set() }).uniques.size })), + hits: hits.length, hits7: hits.filter(h => h.ts >= since7).length, uniques: new Set(hits.map(h => h.v)).size, bots: hits.filter(h => h.bot).length }; +} +async function list(owner) { return J.db.rotations.filter(r => r.owner === norm(owner)).map(pub); } +async function get(owner, id) { const r = J.db.rotations.find(r => r.id === Number(id) && r.owner === norm(owner)); return r ? pub(r) : null; } +async function create(owner, body) { + const name = String(body.name || '').trim().slice(0, 80); if (!name) return { error: 'Give the rotation a name.' }; + if (J.db.rotations.filter(r => r.owner === norm(owner)).length >= 100) return { error: 'That is a lot of rotations. Archive some first.' }; + let code = newCode(); while (J.db.rotations.some(r => r.code === code)) code = newCode(); + const r = { id: (J.db.rotations.reduce((m, x) => Math.max(m, x.id), 0) + 1), owner: norm(owner), name, code, fallback: cleanUrl(body.fallback) || '', destinations: [], created: Date.now(), paused: false, nextDest: 1 }; + J.db.rotations.push(r); J.save(); return { ok: true, rotation: pub(r) }; +} +async function update(owner, id, body) { + const r = J.db.rotations.find(r => r.id === Number(id) && r.owner === norm(owner)); if (!r) return { error: 'No such rotation.' }; + if (body.name != null) { const n = String(body.name).trim().slice(0, 80); if (n) r.name = n; } + if (body.fallback != null) r.fallback = cleanUrl(body.fallback) || ''; + if (body.paused != null) r.paused = !!body.paused; + J.save(); return { ok: true, rotation: pub(r) }; +} +async function remove(owner, id) { const n = J.db.rotations.length; J.db.rotations = J.db.rotations.filter(r => !(r.id === Number(id) && r.owner === norm(owner))); J.save(); return n !== J.db.rotations.length ? { ok: true } : { error: 'No such rotation.' }; } +async function addDest(owner, id, body) { + const r = J.db.rotations.find(r => r.id === Number(id) && r.owner === norm(owner)); if (!r) return { error: 'No such rotation.' }; + const url = cleanUrl(body.url); if (!url) return { error: 'Destination must be a full https:// address.' }; + if (r.destinations.length >= 25) return { error: 'Up to 25 destinations per rotation.' }; + const w = Math.max(1, Math.min(100, Math.round(Number(body.weight) || 1))); + const d = { id: r.nextDest++, url, label: String(body.label || '').trim().slice(0, 60), weight: w, active: true, added: Date.now() }; + r.destinations.push(d); J.save(); return { ok: true, rotation: pub(r) }; +} +async function updateDest(owner, id, did, body) { + const r = J.db.rotations.find(r => r.id === Number(id) && r.owner === norm(owner)); if (!r) return { error: 'No such rotation.' }; + const d = r.destinations.find(d => d.id === Number(did)); if (!d) return { error: 'No such destination.' }; + if (body.url != null) { const u = cleanUrl(body.url); if (!u) return { error: 'Destination must be a full https:// address.' }; d.url = u; } + if (body.label != null) d.label = String(body.label).trim().slice(0, 60); + if (body.weight != null) d.weight = Math.max(1, Math.min(100, Math.round(Number(body.weight) || 1))); + if (body.active != null) d.active = !!body.active; + if (body.remove) r.destinations = r.destinations.filter(x => x.id !== d.id); + J.save(); return { ok: true, rotation: pub(r) }; +} +// pick by weight among active destinations +function pick(r) { + const live = r.destinations.filter(d => d.active); + if (!live.length) return null; + const total = live.reduce((n, d) => n + d.weight, 0); let x = crypto.randomInt(total); + for (const d of live) { x -= d.weight; if (x < 0) return d; } + return live[live.length - 1]; +} +// the redirect: returns { url, dest } or null (unknown code). Records the hit. +async function resolve(code, req) { + const r = J.db.rotations.find(r => r.code === norm(code)); if (!r) return null; + const ua = String(req.headers['user-agent'] || ''); const bot = BOT.test(ua) || !ua; + const d = r.paused ? null : pick(r); + const url = d ? d.url : (r.fallback || null); + const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim(); + const day = new Date().toISOString().slice(0, 10); + const v = crypto.createHash('sha256').update(day + '|' + ip + '|' + ua).digest('hex').slice(0, 16); // daily-salted visitor hash, no IP stored + let country = ''; try { country = geo && geo.countryOf ? (geo.countryOf(ip) || '') : ''; } catch (e) {} + const ref = String(req.headers.referer || ''); let src = 'direct'; try { if (ref) src = new URL(ref).hostname.replace(/^www\./, ''); } catch (e) {} + const device = /mobile|android|iphone|ipad/i.test(ua) ? 'mobile' : 'desktop'; + J.db.hits.push({ r: r.id, d: d ? d.id : 0, ts: Date.now(), v, c: country, s: src, dv: device, bot: bot ? 1 : 0 }); + if (J.db.hits.length > 200000) J.db.hits = J.db.hits.slice(-150000); + J.save(); + return { url, dest: d, rotation: r }; +} +async function stats(owner, id) { + const r = J.db.rotations.find(r => r.id === Number(id) && r.owner === norm(owner)); if (!r) return null; + const hits = J.db.hits.filter(h => h.r === r.id && !h.bot); + const by = key => { const m = {}; for (const h of hits) { const k = h[key] || '(none)'; m[k] = (m[k] || 0) + 1; } return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 12).map(([k, n]) => ({ k, n })); }; + const days = {}; for (const h of hits) { const k = new Date(h.ts).toISOString().slice(0, 10); days[k] = (days[k] || 0) + 1; } + const hours = new Array(24).fill(0); for (const h of hits) hours[new Date(h.ts).getUTCHours()]++; + return { rotation: pub(r), country: by('c'), source: by('s'), device: by('dv'), days: Object.entries(days).sort().slice(-30).map(([k, n]) => ({ day: k, n })), hours, bots: J.db.hits.filter(h => h.r === r.id && h.bot).length }; +} +function totals() { return { rotations: J.db.rotations.length, hits: J.db.hits.length }; } + +module.exports = { init, list, get, create, update, remove, addDest, updateDest, resolve, stats, totals }; diff --git a/sendy.js b/sendy.js new file mode 100644 index 0000000..85a482b --- /dev/null +++ b/sendy.js @@ -0,0 +1,51 @@ +// sendy.js — silent newsletter opt-in via Sendy. +// API key from SENDY_API_KEY env, else DATA_DIR/sendy.key on the volume (same +// pattern as sendgrid.key). URL + list have safe non-secret defaults (the +// hashed list id is public — it appears in Sendy's own subscribe-form HTML). +// subscribe() is fire-and-forget and never throws: a Sendy hiccup must never +// break signup. boolean=true = silent (no confirmation email); Sendy itself +// refuses unsubscribed/bounced addresses, so opt-out always wins. +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); +const URL_BASE = (process.env.SENDY_URL || 'https://valuedreply.xyz').replace(/\/+$/, ''); +const LIST = process.env.SENDY_LIST || 'W892hm1pgk3pIiK7OBYfHTWw'; // LinkSpin Newsletter (brand 3) + +function apiKey() { + if (process.env.SENDY_API_KEY) return process.env.SENDY_API_KEY.trim(); + try { return fs.readFileSync(path.join(DATA_DIR, 'sendy.key'), 'utf8').trim(); } catch (e) { return ''; } +} +function enabled() { return !!apiKey(); } + +function subscribe(email, name) { + const key = apiKey(); + const e = String(email || '').trim(); + if (!key || !e) return Promise.resolve(false); + const body = new URLSearchParams({ api_key: key, list: LIST, email: e, name: String(name || ''), boolean: 'true' }).toString(); + const u = new URL(URL_BASE + '/subscribe'); + return new Promise(resolve => { + const req = https.request({ hostname: u.hostname, path: u.pathname, method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) }, timeout: 10000 }, + res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(/^(1|true|already)/i.test(d.trim()))); }); + req.on('error', () => resolve(false)); + req.on('timeout', () => { req.destroy(); resolve(false); }); + req.end(body); + }); +} + +// 'Subscribed' | 'Unsubscribed' | 'Unconfirmed' | 'Bounced' | 'Soft bounced' | 'Complained' | 'Email does not exist in list' | '' (no key / unreachable) +function status(email) { + const key = apiKey(); const e = String(email || '').trim(); + if (!key || !e) return Promise.resolve(''); + const body = new URLSearchParams({ api_key: key, email: e, list_id: LIST }).toString(); + const u = new URL(URL_BASE + '/api/subscribers/subscription-status.php'); + return new Promise(resolve => { + const req = https.request({ hostname: u.hostname, path: u.pathname, method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) }, timeout: 10000 }, + res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d.trim().slice(0, 60))); }); + req.on('error', () => resolve('')); req.on('timeout', () => { req.destroy(); resolve(''); }); req.end(body); + }); +} +module.exports = { subscribe, enabled, status }; diff --git a/server.js b/server.js new file mode 100644 index 0000000..c65b8f8 --- /dev/null +++ b/server.js @@ -0,0 +1,2864 @@ +// LinkSpin — membership advertising with immutable on-chain settlement. +// Zero-dependency Node server (RM Circle pattern): static pages + JSON API, +// wallet sign-in (SIWE), live contract ledger, sponsor join links. +// +// Chain selection (Amoy rehearsal vs mainnet) lives in data/config.json — +// see chain.js. Wipe the volume's accounts/sessions + flip config = launch. +const http = require('http'); +const https = require('https'); +const dns = require('dns'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const sendy = require('./sendy'); +const { URL } = require('url'); +const chain = require('./chain'); +const auth = require('./auth'); +const accounts = require('./accounts'); +const ads = require('./ads'); +const mailer = require('./mailer'); +const messages = require('./messages'); +const reports = require('./reports'); +const drip = require('./drip'); +const spaces = require('./spaces'); // DO Spaces video storage (inert unless DO_SPACES_* set) +let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ } +const chatbot = require('./chatbot'); +const coach = require('./coach'); // coaching view, nudges, digest, prospects, link stats +const tank = require('./tank'); // holding tank: unsponsored free members, adoptions, pay-it-forward +const legacy = require('./legacy'); // Faucet Wave / Tier One Ads bridge: welcome-back credits for listed emails +const traffic = require('./traffic'); // public page views by referring domain (admin Traffic tab) +const promos = require('./promos'); // partner promo codes -> free ad credits (link ?promo=CODE or the dashboard box) +const blog = require('./blog'); +const adminMember = require('./adminmember'); +const syndicate = require('./syndicate'); +const releases = require('./releases'); +const updates = require('./updates'); +const audit = require('./audit'); // counter audit: views vs delivery logs, charges vs shows (Marty, 2026-09-15) // member update emails from Admin > Releases (Marty, 2026-09-14) +const leaderboard = require('./leaderboard'); +const toolkit = require('./toolkit'); +const registry = require('./registry'); // network registry: who is who across properties (LinkSpin, 2026-09-15) +const carry = require('./carry'); // sponsor carry-over: engine activation, claim window, notices +const rotator = require('./rotator'); // the rotator: rotations, weighted destinations, /r/ +const snapshot = require('./snapshot'); // daily growth snapshot -> Telegram payments feed (Marty, 2026-09-15) +const pipeline = require('./pipeline'); // sponsor follow-up board (coming soon until site setting pipelineMode = on) (Marty, 2026-09-15) +const videomaker = require('./videomaker'); // Circuit tool: promo videos with the member's own end card (ffmpeg in the image) // badge-gated promo toolkit + AI Copy Engine (Surge and up) (Marty, 2026-09-14) // referral contest: /leaderboard, Overview card, weekly + monthly winners (Marty, 2026-09-14) // release notes + roadmap: /whats-new, Overview card, Admin > Releases (Marty, 2026-09-14) // blog -> Blotato -> X + Instagram on publish (Marty, 2026-09-13) // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12) +const TRAFFIC_PAGES = new Set(['/', '/ledger', '/contract', '/shorts', '/plays', '/wallets', '/launch', '/partners', '/earning', '/blog', '/whats-new', '/leaderboard']); +let tankWaitCache = null; // dashboard: who is waiting for a sponsor (refreshed every minute) +const geo = require('./geo'); // viewer country -> tier (DB-IP lite), for campaign targeting +const burner = require('./burner'); // automatic on-chain credit burns (inert without ENGINE_KEY) + +const PORT = Number(process.env.PORT || 3000); +const ROOT = __dirname; +const PUBLIC_DIR = path.join(ROOT, 'public'); +const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data'); +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme'; +const ADMIN_EMAIL = String(process.env.ADMIN_EMAIL || '').trim().toLowerCase(); +const SITE_HOST = String(process.env.SITE_HOST || 'linkspin-test.saasy.top').trim().toLowerCase(); +// Admin portal sessions: email-code sign-in allowlisted to ADMIN_EMAIL, kept +// in the volume so a restart doesn't log the admin out. Separate cookie and +// store from member sessions; the Bearer ADMIN_PASSWORD API path still works. +const ADMIN_SESS_FILE = path.join(DATA_DIR, 'admin-sessions.json'); +const ADMIN_TTL = 12 * 60 * 60 * 1000; +let adminSessions = {}; +try { adminSessions = JSON.parse(fs.readFileSync(ADMIN_SESS_FILE, 'utf8')) || {}; } catch (e) { adminSessions = {}; } +function saveAdminSessions() { + const now = Date.now(); + for (const k of Object.keys(adminSessions)) if (!adminSessions[k] || adminSessions[k].expires < now) delete adminSessions[k]; + try { fs.writeFileSync(ADMIN_SESS_FILE, JSON.stringify(adminSessions), { mode: 0o600 }); } catch (e) {} +} +function mintAdminSession(email) { + const t = crypto.randomBytes(32).toString('hex'); + adminSessions[t] = { email, expires: Date.now() + ADMIN_TTL }; + saveAdminSessions(); + return t; +} +function adminTokenOf(req) { const m = /(?:^|;\s*)iap\.adm=([^;]+)/.exec(req.headers.cookie || ''); return m ? decodeURIComponent(m[1]) : null; } +function adminFromRequest(req) { const t = adminTokenOf(req); const s = t && adminSessions[t]; return (s && s.expires > Date.now()) ? s : null; } +function dropAdminSession(req) { const t = adminTokenOf(req); if (t && adminSessions[t]) { delete adminSessions[t]; saveAdminSessions(); } } +function adminCookie(t) { return 'iap.adm=' + encodeURIComponent(t) + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' + (ADMIN_TTL / 1000) + (IS_PROD ? '; Secure' : ''); } +function clearAdminCookie() { return 'iap.adm=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; } +const IS_PROD = process.env.NODE_ENV === 'production'; +const SITE_FILE = path.join(DATA_DIR, 'site.json'); + +const db = require('./db'); +fs.mkdirSync(DATA_DIR, { recursive: true }); +const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); // solo-ad media lives on the volume +fs.mkdirSync(UPLOADS_DIR, { recursive: true }); +const uploadCounts = new Map(); // email:day -> uploads today +// open earn tokens (ad view / video / visit / tour) live in memory but mirror to the volume so a +// redeploy mid-watch does not lose them (2026-09-13: a member's video watch died in a restart) +const OPEN_TOKENS_FILE = () => path.join(DATA_DIR, 'open-tokens.json'); +const persistedMaps = {}; +let tokenSaveTimer = null; +function saveOpenTokens() { + tokenSaveTimer = null; + try { + const out = {}; + for (const [name, m] of Object.entries(persistedMaps)) out[name] = Object.fromEntries(m); + fs.writeFileSync(OPEN_TOKENS_FILE(), JSON.stringify(out)); + } catch (e) {} +} +function persistedMap(name) { + const m = new Map(); + const touch = () => { if (!tokenSaveTimer) tokenSaveTimer = setTimeout(saveOpenTokens, 500); }; + const set = m.set.bind(m), del = m.delete.bind(m); + m.set = (k, v) => { set(k, v); touch(); return m; }; + m.delete = k => { const r = del(k); if (r) touch(); return r; }; + persistedMaps[name] = m; + return m; +} +function loadOpenTokens() { + let saved = null; try { saved = JSON.parse(fs.readFileSync(OPEN_TOKENS_FILE(), 'utf8')); } catch (e) { return; } + const cutoff = Date.now() - 2 * 3600 * 1000; let n = 0; + for (const [name, m] of Object.entries(persistedMaps)) { + for (const [k, v] of Object.entries(saved[name] || {})) if (v && Number(v.ts || 0) > cutoff) { Map.prototype.set.call(m, k, v); n++; } + } + if (n) console.log('open earn tokens restored:', n); +} +const gauntletTokens = persistedMap('gauntlet'); // email -> welcome-tour token (server-clock dwell floor) +const videoTokens = persistedMap('video'); // email -> watch-to-earn video token (server-clock watch floor) +const faucetHits = new Map(); // address -> last faucet ts (rehearsal test-POL faucet rate limit) +const visitTokens = persistedMap('visit'); // email -> verified-visit token (dwell + captcha floor) +// walk the referral chain upward via sponsorRef (code/username/member id) +async function uplineSlides(email, depth = 3) { + const out = []; + let cur = await accounts.byEmail(email); + for (let i = 0; i < depth && cur; i++) { + const ref = String(cur.sponsorRef || '').trim().toLowerCase(); + if (!ref) break; + let s = null; + if (/^\d+$/.test(ref)) s = await accounts.byMemberId(Number(ref)); + if (!s) s = await accounts.byCode(ref); + if (!s) s = await accounts.byUsername(ref); + if (!s || s.email === cur.email) break; + out.push(s); + cur = s; + } + return out; +} +// Wall ownership ladder: position 1 is always the member's own line banner; +// positions 2 and 3 become theirs at 2 and 5 qualifying buyers (the same +// thresholds that open payout levels 2 and 3). Until then, or while an unlocked +// slot is empty, the slot shows an upline's banner, then a house ad. +const catalogCache = { at: 0, products: null }; +const wallUnlockedFor = bc => (bc >= 5 ? 3 : bc >= 2 ? 2 : 1); +// every on-chain member id this session controls: the main wallet plus linked +// positions (Qualified Start). Credits pool across them on the dashboard. +const memberInfoCache = new Map(); // id -> { t, v } (60s): line views ask for every member's on-chain row +async function cachedMember(id) { + const c = memberInfoCache.get(id); if (c && Date.now() - c.t < 60000) return c.v; + let v = null; try { v = await chain.member(id); } catch (e) {} + memberInfoCache.set(id, { t: Date.now(), v }); return v; +} +async function myMemberIds(s) { + const main = await auth.refreshMemberId(s); + const ids = main ? [main] : []; + if (s && s.email) for (const p of await accounts.positions(s.email)) if (p.memberId && !ids.includes(p.memberId)) ids.push(p.memberId); + return { main, ids }; +} +function parseWallOffers(a) { + try { const v = JSON.parse((a && a.wallOffers) || '[]'); return Array.isArray(v) ? v.slice(0, 2) : []; } catch (e) { return []; } +} +// admin fallback ads for wall positions 2 & 3 when a member has no upline. +// Configurable by dropping data/admin-wall-ads.json ([{name,targetUrl,bannerUrl}]). +function getAdminWallAds() { + try { const j = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'admin-wall-ads.json'), 'utf8')); if (Array.isArray(j) && j.length) return j; } catch (e) {} + return [{ name: 'LinkSpin', targetUrl: 'https://linkspin-test.saasy.top/', bannerUrl: null }]; +} +const chatHits = new Map(); +function chatLimited(ip) { + const now = Date.now(), rec = chatHits.get(ip); + if (!rec || now > rec.reset) { chatHits.set(ip, { count: 1, reset: now + 60000 }); return false; } + rec.count += 1; + return rec.count > 10; +} +// magic-code sign-in: emailLower -> {code, exp, tries} +const emailCodes = new Map(); +// ── sign-up code guard (Marty, 2026-09-11): the email box is one field and one +// tap, so nothing visible stands in a human's way. Bots hit four invisible walls: +// a honeypot field, a minimum form age, per-IP + global send limits, and, only +// once an IP trips a limit, the same icon check the ad viewer uses. +const CODE_LIMITS = { per10m: 5, perDay: 20, globalPerMin: 60, passMs: 5 * 60 * 1000, minFormMs: 2000 }; +const codeHits = new Map(); // ip -> { t: [send timestamps, 24h], passUntil, chal: { answer, exp } } +const codeGlobal = { minute: 0, n: 0 }; +const codeAlert = { last: 0, trips: 0, ips: new Set() }; +function clientIp(req) { return String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim() || 'unknown'; } +// the viewer's country and tier for ad targeting (null tier = unknown: never matches a restricted campaign) +function viewerGeo(req) { const cc = geo.countryOf(clientIp(req)); return { cc, tier: geo.tierOf(cc, siteConfig()) }; } +function codeChallenge(rec) { + const pick = CAPTCHA.slice().sort(() => Math.random() - 0.5).slice(0, 5); + const answer = Math.floor(Math.random() * pick.length); + rec.chal = { answer: pick[answer][0], exp: Date.now() + 5 * 60 * 1000 }; + return { prompt: pick[answer][1], options: pick.map(x => x[0]) }; +} +// returns null to allow the send, or { status, body } to answer with instead +const guardLog = (req, why, b) => console.log('signup-guard', why, clientIp(req), String(b.email || '').replace(/^(.).*(@.*)$/, '$1***$2')); +function codeGuard(req, b) { + const now = Date.now(); + // honeypot: bots fill it, humans never see it. Some form-filler extensions fill every field, + // hidden or not (seen 2026-09-11), so a filled honeypot is a CHALLENGE, never a silent drop: + // a person passes the icon check and gets the code, a bot cannot. + const hpRaw = String(b.hp_field_x9 || '').trim(); + const hpIsEmail = !!hpRaw && hpRaw.toLowerCase() === String(b.email || '').trim().toLowerCase(); + const hp = !!hpRaw && !hpIsEmail; // 2026-09-13: phones were autofilling the hidden field; own email = autofill, not a bot + if (hpRaw) console.log('signup-guard honeypot-value', clientIp(req), hpIsEmail ? 'own-email' : (/^https?:/i.test(hpRaw) ? 'url' : /@/.test(hpRaw) ? 'other-email' : /^\+?[\d\s()-]{6,}$/.test(hpRaw) ? 'phone' : 'text'), 'len=' + hpRaw.length, hpIsEmail ? '' : hpRaw.slice(0, 2) + '***'); + const fts = Number(b.fts) || 0; + if (!fts || now - fts < CODE_LIMITS.minFormMs) { guardLog(req, 'form-age', b); return { status: 400, body: { error: 'Give the page a second, then tap again.' } }; } + if (now - fts > 12 * 3600 * 1000) { guardLog(req, 'form-stale', b); return { status: 400, body: { error: 'This page has been open a long time. Refresh it, then tap again.' } }; } + const minute = Math.floor(now / 60000); + if (codeGlobal.minute !== minute) { codeGlobal.minute = minute; codeGlobal.n = 0; } + if (codeGlobal.n >= CODE_LIMITS.globalPerMin) { guardLog(req, 'global-limit', b); codeTrip(req, 'global'); return { status: 429, body: { error: 'Busy right now. Try again in a minute.' } }; } + const ip = clientIp(req); + const rec = codeHits.get(ip) || { t: [], passUntil: 0, chal: null }; + rec.t = rec.t.filter(ts => now - ts < 24 * 3600 * 1000); + const n10 = rec.t.filter(ts => now - ts < 10 * 60 * 1000).length; + const limited = hp || n10 >= CODE_LIMITS.per10m || rec.t.length >= CODE_LIMITS.perDay; + if (limited && now >= rec.passUntil) { + const pick = String(b.pick || ''); + if (pick && rec.chal && rec.chal.exp > now && pick === rec.chal.answer) { rec.passUntil = now + CODE_LIMITS.passMs; rec.chal = null; } + else { + guardLog(req, pick ? 'wrong-pick' : hp ? 'honeypot-challenge' : 'ip-limit', b); if (!hp) codeTrip(req, ip); + const challenge = codeChallenge(rec); codeHits.set(ip, rec); + return { status: 429, body: { error: pick ? 'That was not it. Try once more.' : 'Quick check before we send another code.', challenge } }; + } + } + rec.t.push(now); codeHits.set(ip, rec); codeGlobal.n += 1; + if (codeHits.size > 5000) for (const [k, v] of codeHits) { if (!v.t.length || now - v.t[v.t.length - 1] > 24 * 3600 * 1000) codeHits.delete(k); } + return null; +} +// burst alert: at most one message per 10 minutes, to the admin Telegram chat if set, else the admin email +function codeTrip(req, ip) { + codeAlert.trips += 1; codeAlert.ips.add(ip); + if (Date.now() - codeAlert.last < 10 * 60 * 1000) return; + codeAlert.last = Date.now(); + const text = '\u26A0\uFE0F LinkSpin sign-up guard: ' + codeAlert.trips + ' blocked code request' + (codeAlert.trips === 1 ? '' : 's') + ' from ' + codeAlert.ips.size + ' source' + (codeAlert.ips.size === 1 ? '' : 's') + ' (' + [...codeAlert.ips].slice(0, 5).join(', ') + ') in the last window.'; + codeAlert.trips = 0; codeAlert.ips = new Set(); + const sc = siteConfig(); + if (sc.telegramBotToken && sc.telegramAdminChatId) telegramSend(sc.telegramAdminChatId, text).catch(() => {}); + else if (ADMIN_EMAIL && mailer.hasKey()) mailer.send(ADMIN_EMAIL, 'LinkSpin: sign-up guard tripped', text).catch(() => {}); +} +// earn-view tokens: emailLower -> {token, ts} (one live token per member) +const earnTokens = persistedMap('earn'); +// human-check pairs for the view verifier: [emoji shown, word named in the prompt] +const CAPTCHA = [['🚀', 'rocket'], ['⚡', 'lightning bolt'], ['🔑', 'key'], ['🎯', 'target'], + ['🌊', 'wave'], ['🔥', 'flame'], ['💎', 'diamond'], ['🧲', 'magnet'], ['🔔', 'bell'], ['🌙', 'moon']]; + +// ── frame-breaking check ───────────────────────────────── +// Surf views frame the advertiser's URL full screen, so a target that refuses +// framing (X-Frame-Options / CSP frame-ancestors) would burn members' views on +// a blank frame. Catch it the moment the campaign is submitted. The lookup +// also refuses private/internal addresses so member URLs can't probe our LAN. +const PRIVATE_IP = /^(127\.|10\.|192\.168\.|169\.254\.|0\.|172\.(1[6-9]|2\d|3[01])\.|::1$|::$|f[cd])/i; +function frameFetch(url, depth) { + return new Promise(resolve => { + let u; + try { u = new URL(String(url || '')); } catch (e) { return resolve({ error: 'that is not a valid URL' }); } + if (!/^https?:$/.test(u.protocol)) return resolve({ error: 'only http(s) URLs work' }); + if (u.port && u.port !== '80' && u.port !== '443') return resolve({ error: 'custom ports are not allowed' }); + if (u.hostname === 'localhost' || u.hostname.endsWith('.local')) return resolve({ error: 'that address is not reachable from here' }); + dns.lookup(u.hostname, (de, addr) => { + if (de) return resolve({ error: 'that domain does not resolve' }); + if (PRIVATE_IP.test(addr)) return resolve({ error: 'that address is not reachable from here' }); + const mod = u.protocol === 'https:' ? https : http; + const rq = mod.get(u.href, { timeout: 8000, + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; LinkSpin-FrameCheck/1.0)', Accept: 'text/html' } }, r => { + const loc = r.headers.location; + r.resume(); // headers are all we need + if ([301, 302, 303, 307, 308].includes(r.statusCode) && loc && depth < 4) { + rq.destroy(); + let next; try { next = new URL(loc, u.href).href; } catch (e2) { return resolve({ error: 'it redirects somewhere invalid' }); } + return resolve(frameFetch(next, depth + 1)); // every hop re-runs the private-IP guard + } + resolve({ status: r.statusCode, xfo: r.headers['x-frame-options'] || '', csp: r.headers['content-security-policy'] || '' }); + rq.destroy(); + }); + rq.on('timeout', () => { rq.destroy(); resolve({ error: 'it did not answer within 8 seconds' }); }); + rq.on('error', e2 => resolve({ error: 'it did not answer (' + (e2.code || 'connection failed') + ')' })); + }); + }); +} +// shared upload path for member creatives (/api/my/upload) and admin house-ad +// creatives (/api/admin/upload): `who` keys the per-day upload counter +async function handleUpload(req, res, who) { + const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase(); + const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', + 'video/mp4': 'mp4', 'video/webm': 'webm' }; + if (!EXT[ct]) return json(res, 400, { error: 'Use a PNG, JPG, WebP, GIF, MP4 or WebM file.' }); + const isVideo = ct.startsWith('video/'); + const key = who + ':' + new Date().toISOString().slice(0, 10); + if ((uploadCounts.get(key) || 0) >= 10) return json(res, 400, { error: 'Upload limit for today reached (10 files).' }); + let buf; + try { buf = await readRaw(req, isVideo ? 25 * 1024 * 1024 : 3 * 1024 * 1024); } + catch (e) { return json(res, 400, { error: 'File too large. Images up to 3MB, video up to 25MB.' }); } + const magicOk = buf.length > 16 && ( + (ct === 'image/png' && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) || + (ct === 'image/jpeg' && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) || + (ct === 'image/webp' && buf.slice(0, 4).toString() === 'RIFF' && buf.slice(8, 12).toString() === 'WEBP') || + (ct === 'image/gif' && buf.slice(0, 4).toString() === 'GIF8') || + (ct === 'video/mp4' && buf.slice(4, 8).toString() === 'ftyp') || + (ct === 'video/webm' && buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3)); + if (!magicOk) return json(res, 400, { error: 'That file does not look like a real ' + EXT[ct].toUpperCase() + '.' }); + const name = crypto.randomBytes(12).toString('hex') + '.' + EXT[ct]; + uploadCounts.set(key, (uploadCounts.get(key) || 0) + 1); + // video goes to DO Spaces when configured (keeps big files off the volume); + // images stay local. Falls back to the volume if Spaces isn't set or errors. + if (isVideo && spaces.enabled()) { + try { + const url = await spaces.put('iap-uploads/' + name, buf, ct); + return json(res, 200, { url, type: 'video' }); + } catch (e) { console.error('spaces put', e.message); /* fall through to volume */ } + } + fs.writeFileSync(path.join(UPLOADS_DIR, name), buf); + return json(res, 200, { url: '/uploads/' + name, type: isVideo ? 'video' : 'image' }); +} +// lead-capture page hooks (og tags + copy live in public/assets/join.js too) +const JOIN_ALIASES = { company: 'martbost', top: 'martbost' }; // neutral partner links -> the top position +const JOIN_ANGLES = { + // legacy bridge pages (/from/): former members of Marty's closed EvolutionScript sites + 'fw-adv': { t: 'Your next ad budget pays you back.', d: 'Faucet Wave closed. LinkSpin was built for the people who bought ads there: packages from $5, seven formats, every package in your line paid out on Polygon in the same transaction. Welcome-back credits waiting.', url: 'https://linkspin-test.saasy.top/from/faucetwave?seg=advertiser' }, + 'fw-earn': { t: 'Same daily habit. Real payouts on-chain.', d: 'You viewed ads on Faucet Wave. Here you view ads to earn credits, run your own campaign free, and get paid in POL to your own wallet when your line buys ads. Welcome-back credits waiting.', url: 'https://linkspin-test.saasy.top/from/faucetwave' }, + 't1-adv': { t: 'Your next ad budget pays you back.', d: 'Tier One Ads closed. LinkSpin was built for the people who bought ads there: packages from $5, seven formats, every package in your line paid out on Polygon in the same transaction. Welcome-back credits waiting.', url: 'https://linkspin-test.saasy.top/from/tieroneads?seg=advertiser' }, + 't1-earn': { t: 'Same daily habit. Real payouts on-chain.', d: 'You viewed ads on Tier One Ads. Here you view ads to earn credits, run your own campaign free, and get paid in POL to your own wallet when your line buys ads. Welcome-back credits waiting.', url: 'https://linkspin-test.saasy.top/from/tieroneads' }, + instant: { t: 'Paid before the page reloads.', d: 'A smart contract on Polygon splits every ad package the moment it sells. Same transaction, real wallets, public ledger. Join free by email.' }, + adspend: { t: 'You were buying ads anyway.', d: 'Here the ad spend in your line pays you, in the same transaction, on a public ledger. Seven formats, packages from $5. Join free.' }, + free: { t: 'Watch first. Spend never.', d: 'Join free, view a few ads, earn credits, run your first campaign for zero dollars. Every payout public on Polygon.' }, + ledger: { t: 'No back office. No payday.', d: 'Every payout is a public transaction on Polygon you can read yourself. Nothing is ever held. Join free by email.' }, + two: { t: 'Two buyers open level two.', d: 'Every direct buyer pays you 50 percent from their first package. Two qualifying buyers open level two, five open level three. Written in a verified contract.' } +}; +function serveJoinPage(res, tok, angle, ang, setCookies) { + let html; + try { html = fs.readFileSync(path.join(PUBLIC_DIR, 'join.html'), 'utf8'); } catch (e) { res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' })); return res.end('Not found'); } + const base = 'https://linkspin-test.saasy.top'; + const url = (ang && ang.url) || (base + '/join/' + tok + (angle ? '?v=' + angle : '')); + const title = ang ? ang.t : 'Advertise and earn. Paid on-chain, instantly.'; + const desc = ang ? ang.d : 'You are invited to LinkSpin: real ad packages with same-transaction payouts on Polygon, every payment public. Join free by email.'; + const escA = t => String(t).replace(/&/g, '&').replace(/"/g, '"').replace(/' + + '' // keeps ?v= so shares stay on the angle + + '' + + ''; + html = html.replace(/[^<]*<\/title>/, '<title>' + escA(title) + ' | LinkSpin' + og); + if (ang) html = html.replace('', ''); // angle pages: squeeze layout from the first paint + const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store, must-revalidate' }; + if (setCookies && setCookies.length) headers['Set-Cookie'] = setCookies; + res.writeHead(200, baseHeaders(headers)); + res.end(html); +} +async function frameCheck(url) { + const h = await frameFetch(url, 0); + if (h.error) return { ok: false, reason: 'We checked your URL and ' + h.error + '. Fix the URL and try again.' }; + if (h.status >= 400) return { ok: false, reason: 'Your URL answers with HTTP ' + h.status + '. Point the campaign at a working page.' }; + if (/deny|sameorigin/i.test(String(h.xfo))) + return { ok: false, reason: 'That site blocks framing (X-Frame-Options), so it would show members a blank page in the ad viewer. Use a landing page that allows framing.' }; + const fa = /frame-ancestors\s+([^;]+)/i.exec(String(h.csp)); + if (fa && !fa[1].split(/\s+/).some(x => { + const v = x.replace(/['"]/g, '').toLowerCase(); + return v === '*' || v === 'https:' || v.includes('linkspin-test.saasy.top'); + })) + return { ok: false, reason: 'That site blocks framing (CSP frame-ancestors), so it would show members a blank page in the ad viewer. Use a landing page that allows framing.' }; + return { ok: true }; +} +async function boot() { + await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode) + chain.init({ onEvent: ev => { attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)); emailOnEvent(ev).catch(() => {}); telegramOnEvent(ev).catch(() => {}); } }); + auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'linkspin-test.saasy.top' }); + accounts.init({ dataDir: DATA_DIR }); + ads.init({ dataDir: DATA_DIR, chain }); + mailer.init({ dataDir: DATA_DIR }); + messages.init({ dataDir: DATA_DIR }); + reports.init({ dataDir: DATA_DIR }); + drip.init({ dataDir: DATA_DIR, mailer, accounts, chain, site: 'https://linkspin-test.saasy.top' }); + chatbot.init({ dataDir: DATA_DIR, chain }); + 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.scheduleSweep().catch(e => console.error('schedule sweep', e.message)), 5 * 60 * 1000); // scheduled starts/ends + // follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot) + coach.init({ dataDir: DATA_DIR, chain, accounts, mailer, ads, tank, lb: () => leaderboard, siteConfig }); + pipeline.init({ dataDir: DATA_DIR, accounts, coach, chain }); + registry.init({ dataDir: DATA_DIR }); + carry.init({ dataDir: DATA_DIR, accounts, registry, chain, mailer, messages, siteConfig, adminEmail: ADMIN_EMAIL, host: SITE_HOST }); + rotator.init({ dataDir: DATA_DIR, geo }); + snapshot.init({ dataDir: DATA_DIR, db, chain, siteConfig, send: async (c, t, th) => { await telegramSend(c, t, th); return true; } }); + setInterval(() => snapshot.tick().catch(e => console.error('snapshot', e.message)), 10 * 60 * 1000); + tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://linkspin-test.saasy.top' }); + legacy.init({ dataDir: DATA_DIR }); + traffic.init({ dataDir: DATA_DIR }); + promos.init({ dataDir: DATA_DIR }); + blog.init({ dataDir: DATA_DIR }); + adminMember.init({ accounts, ads, chain, tank, legacy, promos, messages, dataDir: DATA_DIR }); + loadOpenTokens(); + syndicate.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR, uploadsDir: UPLOADS_DIR }); + releases.init({ dataDir: DATA_DIR }); + toolkit.init({ dataDir: DATA_DIR, ads, accounts, siteConfig, coach, messages, promos, videomaker, chain }); + updates.init({ dataDir: DATA_DIR, accounts, releases, mailer, drip, sendy, adminEmail: ADMIN_EMAIL }); + audit.init({ dataDir: DATA_DIR, notify: text => { const sc = siteConfig(); if (sc.telegramBotToken && sc.telegramAdminChatId) telegramSend(sc.telegramAdminChatId, text).catch(() => {}); else if (ADMIN_EMAIL && mailer.hasKey()) mailer.send(ADMIN_EMAIL, 'LinkSpin: counter audit', text).catch(() => {}); } }); + setTimeout(() => audit.dailyTick(), 5 * 60 * 1000); setInterval(() => audit.dailyTick(), 24 * 60 * 60 * 1000); + videomaker.init({ dataDir: DATA_DIR, spaces, accounts }); + leaderboard.init({ chain, accounts, ads, dataDir: DATA_DIR, siteConfig, pushFeed, adminEmail: ADMIN_EMAIL, + notify: async text => { const sc = siteConfig(); if (!sc.telegramBotToken || !sc.telegramEchoChatId) return; await telegramSend(sc.telegramEchoChatId, text, sc.telegramEchoTopicId); if (String(sc.leaderboardAnnounceGeneral || '1') !== '0') await telegramSend(sc.telegramEchoChatId, text, null); } }); + setTimeout(() => leaderboard.rolloverTick().catch(e => console.error('leaderboard rollover', e.message)), 90 * 1000); + setInterval(() => leaderboard.rolloverTick().catch(e => console.error('leaderboard rollover', e.message)), 60 * 60 * 1000); + console.log('blog syndication:', syndicate.enabled() ? 'on (X + Instagram via Blotato)' : 'off (no blotato.key)'); + setInterval(() => tankNotifyTick().catch(e => console.error('tank notify', e.message)), 15 * 60 * 1000); // new tank arrivals -> Telegram + geo.init({ dataDir: DATA_DIR }).catch(e => console.error('geo init', e.message)); + setInterval(() => geo.refresh().catch(e => console.error('geo refresh', e.message)), 24 * 3600 * 1000); // monthly file, checked daily + setInterval(() => tank.sweep().catch(e => console.error('tank sweep', e.message)), 60 * 60 * 1000); // adoptions past their 7-day window + burner.init({ chain, ads, accounts }); + setTimeout(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 90 * 1000); + setInterval(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 60 * 60 * 1000); + setTimeout(() => burner.tick().catch(e => console.error('burner', e.message)), 45 * 1000); + setInterval(() => burner.tick().catch(e => console.error('burner', e.message)), 5 * 60 * 1000); + setTimeout(() => drip.tick().catch(e => console.error('drip', e.message)), 30 * 1000); + setInterval(() => drip.tick().catch(e => console.error('drip', e.message)), 10 * 60 * 1000); + // NAS reconcile: pull syndicated delivery into the unified credit pool + // (inert unless NAS_DB_* is set). Every 5 min after a short warm-up. + if (ads.nasEnabled()) { + console.log('NAS syndication enabled'); + setTimeout(() => ads.reconcileNas().catch(e => console.error('nas reconcile', e.message)), 90 * 1000); + setInterval(() => ads.reconcileNas().catch(e => console.error('nas reconcile', e.message)), 5 * 60 * 1000); + } +} + +function siteConfig() { + let saved = {}; + try { saved = JSON.parse(fs.readFileSync(SITE_FILE, 'utf8')); } catch (e) {} + return Object.assign({ + siteName: 'LinkSpin', + tagline: 'Advertise and earn. Locked in code, not promises.', + rehearsal: true, // shows the testnet banner; flipped off at mainnet launch + // payment-proof Telegram feed (blank = off) and the P&L pane's fixed monthly cost + telegramBotToken: '', telegramChatId: '', telegramTopicId: '', telegramEvents: 'payouts', telegramCtaUrl: 'https://linkspin-test.saasy.top/', + telegramEchoChatId: '', telegramEchoTopicId: '', telegramEchoEvents: 'payouts', // shared cross-program payments topic + telegramAdminChatId: '', // private chat for admin alerts (sign-up guard bursts); falls back to ADMIN_EMAIL + launchAt: '', // public launch moment, ISO 8601 with offset (e.g. 2026-09-18T19:00:00-05:00): countdown on /launch + dashboard mark + aiCreditsPerGen: 10, aiFreeSurge: 20, aiFreeCircuit: 60, aiFreeNexus: 150, // AI Copy Engine: free generations per month by badge, then credits per generation + carryClaimDays: '3', // sponsor with no wallet: days to link one before a downline seat walks up (LinkSpin) + linkDomains: '', // comma list of short link domains this server answers for (each gets a real mini-site + /r/ redirects) + snapshotEnabled: '1', snapshotHourUtc: '14', snapshotTargets: 'feed,echo', // daily growth snapshot: 14 UTC = 9 AM Central; feed = proof channel, echo = shared payments topic + pipelineMode: 'off', // Pipeline board: off = 'coming soon' card, preview = only the admin account sees the live board, on = everyone (Marty, 2026-09-15) + pipelineEta: 'Sep 28', // what the coming-soon card and the roadmap promise + memberWeeklyEmail: '0', // 1 = the weekly 'Your week on LinkSpin' email goes to every active member, not only sponsors with a line + noPayoutIds: '25', // positions kept for linkage only (compromised wallets): no buys signed from them, no new joins routed under them or their upline chain (Marty, 2026-09-14) + leaderboardWeeklyPrize: '', leaderboardMonthlyPrize: '', leaderboardWeeklyCredits: '1000,500,250', leaderboardMonthlyCredits: '5000,2500,1000', leaderboardAnnounceGeneral: '1', // referral contest prizes (text shown on /leaderboard; credits granted to the winner automatically at rollover) + legacyCreditsAdvertiser: 500, legacyCreditsEarner: 150, // welcome-back credits for listed Faucet Wave / Tier One Ads emails arriving via /from/ + geoTier1: '', // comma-separated ISO country codes; empty = built-in default (US, CA, GB, AU, NZ, IE, DE, FR, NL, SE, NO, DK, FI, CH, AT, BE) + geoTier2: '', // empty = built-in default (rest of Western/Central Europe, JP, KR, SG, HK, TW, IL, Gulf, ZA, BR, MX, AR, CL, CO ...); tier 3 = everything else + pnlFixedMonthlyUsd: 0 + }, saved); +} + +// ---- helpers ---- +const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', + '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.webp': 'image/webp', + '.ico': 'image/x-icon', '.json': 'application/json', '.mp4': 'video/mp4', '.woff2': 'font/woff2', + '.gif': 'image/gif', '.webm': 'video/webm', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml; charset=utf-8' }; +const CSP = "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net 'sha256-NzvNrqk5jB9YZATwo5BF4JoRlJ02HsnFikbKXgEPdaQ='; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; media-src 'self' https: blob:; connect-src 'self' https://*.walletconnect.com wss://*.walletconnect.com https://*.walletconnect.org wss://*.walletconnect.org https://*.reown.com wss://*.reown.com https://*.reown.org wss://*.reown.org https://*.web3modal.org https://*.drpc.org https://*.publicnode.com https://*.coinbase.com; font-src 'self' data: https://fonts.gstatic.com https://fonts.reown.com; form-action 'self'; frame-src https: http:"; +function baseHeaders(extra) { + return Object.assign({ 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'strict-origin-when-cross-origin' }, extra || {}); +} +function json(res, code, obj, extra) { + const body = JSON.stringify(obj); + res.writeHead(code, baseHeaders(Object.assign({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, extra))); + res.end(body); +} +function sendFile(res, file) { + fs.readFile(file, (err, data) => { + if (err) { res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' })); return res.end('Not found'); } + const ext = path.extname(file).toLowerCase(); + res.writeHead(200, baseHeaders({ 'Content-Type': MIME[ext] || 'application/octet-stream', + // HTML is never stored (so a fresh load always gets the current asset + // versions — aggressive in-app wallet browsers were serving stale pages + // that pointed at old, since-fixed JS); versioned assets cache for an hour. + 'Cache-Control': ext === '.html' ? 'no-store, must-revalidate' : 'public, max-age=3600' })); + res.end(data); + }); +} +function readRaw(req, maxBytes) { + return new Promise((resolve, reject) => { + const chunks = []; + let n = 0; + req.on('data', c => { + n += c.length; + if (n > maxBytes) { req.destroy(); reject(new Error('too big')); return; } + chunks.push(c); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} +function readBody(req) { + return new Promise((resolve, reject) => { + let d = ''; let n = 0; + req.on('data', c => { n += c.length; if (n > 64 * 1024) { req.destroy(); reject(new Error('too big')); } d += c; }); + req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch (e) { reject(e); } }); + req.on('error', reject); + }); +} +function parseCookies(req) { + const out = {}; + for (const p of (req.headers.cookie || '').split(';')) { + const i = p.indexOf('='); if (i > 0) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); + } + return out; +} +function isAdmin(req) { + const h = req.headers.authorization || ''; + if (h === 'Bearer ' + ADMIN_PASSWORD) return true; + return !!adminFromRequest(req); // /admin portal session +} +// attach a memberId->username map to events so activity shows real people +async function attachNames(evts) { + try { + const ids = []; + for (const ev of evts) + for (const k of ['id', 'buyerId', 'recipientId', 'skippedId', 'sponsorId', 'newBuyerId', 'toId', 'memberId']) + if (ev[k]) ids.push(ev[k]); + const names = await accounts.namesForMembers(ids); + if (!Object.keys(names).length) return evts; + return evts.map(ev => Object.assign({}, ev, { names })); + } catch (e) { return evts; } +} +// A sponsor token is a numeric chain id or a site share code. Codes resolve +// to the referrer's CURRENT chain id, so activation any time before the +// referral's first purchase still locks the line to them. +// No-payout positions (siteConfig.noPayoutIds): a member id whose on-chain wallet must never be paid again. +// A purchase pays the buyer's three uplines, so a buy from `id` is blocked when id itself or any of its three +// uplines is listed; a join under `id` is blocked when id or its two uplines is listed (the new member's buys +// would reach the listed wallet at tier 2 or 3). Returns the listed id that would be hit, else 0. +function noPayoutSet() { return new Set(String(siteConfig().noPayoutIds || '').split(/[,\s]+/).map(Number).filter(n => n > 0)); } +async function payoutChainBlocked(id, hops) { + const bad = noPayoutSet(); if (!bad.size || !id) return 0; + let cur = Number(id); + for (let i = 0; i <= hops && cur; i++) { if (bad.has(cur)) return cur; let m = null; try { m = await chain.member(cur); } catch (e) { break; } cur = m ? Number(m.sponsorId) || 0 : 0; } + return 0; +} +// Why a sponsor token did not resolve matters (vladz79 → #24 locked under the company on 2026-09-13 because a +// lookup came back empty and the code fell back silently): 'ok' | 'none' (no token) | 'unknown' (no such account) +// | 'notActivated' (sponsor has no wallet / payouts off) | 'rpc' (chain lookup failed right now). +async function resolveSponsorDetailed(tok) { + const t = String(tok || '').trim().toLowerCase(); + if (!t) return { id: 0, reason: 'none' }; + if (/^\d+$/.test(t)) return { id: Number(t), reason: 'ok' }; + let acct = await accounts.byCode(t); + if (!acct) acct = await accounts.byUsername(t); // vanity links: /join/ + if (!acct) return { id: 0, reason: 'unknown', name: t }; + if (!acct.address) return { id: 0, reason: 'notActivated', name: acct.username ? '@' + acct.username : t }; + try { const id = await chain.memberIdByAccount(acct.address); return { id, reason: id ? 'ok' : 'notActivated', name: acct.username ? '@' + acct.username : t }; } + catch (e) { return { id: 0, reason: 'rpc', name: acct.username ? '@' + acct.username : t }; } +} +async function resolveSponsorToken(tok) { return (await resolveSponsorDetailed(tok)).id; } +const sponsorAlertLast = new Map(); // email -> ts (one alert per member per hour) +function sponsorBlockedAlert(who, r) { + const k = String(who || '?'); if (Date.now() - (sponsorAlertLast.get(k) || 0) < 3600000) return; sponsorAlertLast.set(k, Date.now()); + const text = '\u26A0\uFE0F LinkSpin: purchase held for ' + k.replace(/^(.{2}).*(@.*)$/, '$1***$2') + '. Their sponsor ' + (r.name || r.tok || '?') + ' could not be resolved (' + r.reason + '), so the buy was blocked instead of crediting the company. ' + (r.reason === 'notActivated' ? 'The sponsor needs to switch on payouts.' : r.reason === 'rpc' ? 'Chain lookup failed; they can retry.' : 'Check the sponsor field in Admin > Members.'); + const sc = siteConfig(); + if (sc.telegramBotToken && sc.telegramAdminChatId) telegramSend(sc.telegramAdminChatId, text).catch(() => {}); + else if (ADMIN_EMAIL && mailer.hasKey()) mailer.send(ADMIN_EMAIL, 'LinkSpin: purchase held, sponsor unresolved', text).catch(() => {}); +} +// The moment someone joins through a code, nudge its owner to activate. +// Email a member's sponsor the moment they get a new referral (free OR paid). +// Resolves the sponsor from the join token by member id, share code, or username, +// and notifies EVERY sponsor — activated or not (an active sponsor still wants to +// know their team grew). A referral is on the line from signup; it only counts +// toward qualification once it makes a $20+ purchase. +async function notifyNewReferral(ref, newAcct) { + try { + if (!mailer.hasKey()) return; + const t = String(ref || '').trim().toLowerCase(); + if (!t) return; + let owner = null; + if (/^\d+$/.test(t)) { try { owner = await accounts.byMemberId(Number(t)); } catch (e) {} } + if (!owner) { try { owner = await accounts.byCode(t); } catch (e) {} } + if (!owner) { try { owner = await accounts.byUsername(t); } catch (e) {} } + if (!owner || !owner.email) return; + const who = newAcct && newAcct.username ? '@' + newAcct.username : 'A new member'; + let body = who + ' just joined LinkSpin through your link — they are on your team from today.\n\n' + + 'They count toward your qualification once they make a $20+ purchase.\n\n'; + if (!owner.address) body += 'Make sure payouts are switched on (one free wallet step) so you never miss a commission — ' + + 'the contract locks each buyer to their sponsor at their first purchase.\n\n'; + body += 'See your team: https://linkspin-test.saasy.top/my\n\nLinkSpin'; + mailer.send(owner.email, 'You have a new referral on LinkSpin', body) + .catch(e => console.error('referral notify failed', e.message)); + } catch (e) {} +} +// welcome email on a new account: onboarding steps + who their sponsor is +// how a member is named in emails/alerts: @username, a linked position named after its owner, else member #id +async function memberLabel(id) { + try { + let a = await accounts.byMemberId(id); + if (!a) { // brand-new buyer: the account learns its member id on its next page load, so read the + // wallet from the chain and match it now (Jim's purchase mailed as "member #25", 2026-09-13) + try { const m = await chain.member(id); if (m && m.account) { a = await accounts.byAddress(String(m.account).toLowerCase()); if (a && !a.memberId) accounts.setMemberId(a.email, id).catch(() => {}); } } catch (e) {} + } + if (a && a.username) return '@' + a.username; + const pos = await accounts.positionByMember(id); + if (pos && pos.email) { const o = await accounts.byEmail(pos.email); if (o && o.username) return '@' + o.username + ' (extra position #' + id + ')'; } + } catch (e) {} + return 'member #' + id; +} +async function sendWelcome(email, ref) { + try { + if (!mailer.hasKey()) return; + const spon = await accounts.sponsorOf(email); + const who = spon ? (spon.username ? '@' + spon.username : 'member #' + (spon.memberId || 0)) : ''; + const sponsorLine = who ? ('You joined through ' + who + ', your sponsor. They are there to help you get started, and you can message them anytime from your dashboard.\n\n') : ''; + mailer.send(email, 'Welcome to LinkSpin', + 'Your free LinkSpin account is ready.\n\n' + sponsorLine + + 'Getting started:\n' + + '1. Pick your username and fill out your profile.\n' + + '2. Grab your invite link and start sharing to build your line.\n' + + '3. Explore the ad packages when you are ready. Every payout settles on-chain, straight to your wallet.\n\n' + + 'Sign in anytime: https://linkspin-test.saasy.top/my\n\nLinkSpin').catch(() => {}); + } catch (e) {} +} +// on-chain event emails: a payout received, or a payout that passed you by +const weiToPol = w => { try { return (Number(BigInt(w) / (10n ** 14n)) / 10000).toString(); } catch (e) { return '?'; } }; +async function emailOnEvent(ev) { + if (!ev) return; + const notify = async (memberId, subject, body) => { + if (!memberId || !mailer.hasKey()) return; + const a = await accounts.byMemberId(memberId); + if (a && a.email) mailer.send(a.email, subject, body + '\n\nSee it on the live ledger: https://linkspin-test.saasy.top/ledger\n\nLinkSpin').catch(() => {}); + }; + // Marty (2026-09-15): payment and missed-payment notices also land in the on-site inbox, so + // they are waiting (login modal + Messages card) whether or not the email was read. Sent as + // the company account (#1), the same sender the credit-return notes used. Plain text in, HTML out. + const inboxNote = async (memberId, subject, text) => { + if (!memberId) return; + try { + const a = await accounts.byMemberId(memberId); + if (!a || !a.email) return; + const html = '

      ' + String(text).replace(/&/g, '&').replace(//g, '>') + .replace(/(https:\/\/[^\s]+)/g, '$1').split('\n\n').join('

      ').replace(/\n/g, '
      ') + '

      '; + await messages.deliver(1, ADMIN_EMAIL || 'house@linkspin-test.saasy.top', [a.email], subject, html); + } catch (e) {} + }; + const tell = async (memberId, subject, text) => { await notify(memberId, subject, text); await inboxNote(memberId, subject, text); }; + const PCT = { 1: 50, 2: 20, 3: 10 }; + // the Purchase event of the same tx is already indexed (it precedes every payout log), so the + // dollar side of any share is that purchase's price times the tier percentage + const purchaseOf = tx => { try { return chain.recentEvents(600).find(e => e.tx === tx && e.type === 'Purchase'); } catch (e) { return null; } }; + const usdShare = (pur, pct) => pur ? (' (about ' + ('$' + (pur.priceCents * pct / 10000).toFixed(2)).replace(/\.00$/, '') + ')') : ''; + const txUrlOf = tx => { const cc = chain.getConfig(); return (cc.explorer ? cc.explorer.replace(/\/+$/, '') : 'https://polygonscan.com') + '/tx/' + tx; }; + if (ev.type === 'Purchase') { + const cc = chain.getConfig(); + const txUrl = (cc.explorer ? cc.explorer.replace(/\/+$/, '') : 'https://polygonscan.com') + '/tx/' + ev.tx; + let bal = ev.creditAmount; + try { bal = await chain.creditBalance(ev.buyerId, ev.creditType); } catch (e) {} + await notify(ev.buyerId, 'Your LinkSpin purchase is confirmed', + 'Your purchase is complete and settled on-chain.\n\n' + + 'Ad credits added: ' + ev.creditAmount + '\n' + + 'Your ad-credit balance is now: ' + bal + '\n' + + 'Amount paid: ' + weiToPol(ev.paidWei) + ' POL\n\n' + + 'View your transaction on the blockchain:\n' + txUrl); + // tell the buyer's DIRECT sponsor their referral just bought (upline earners + // are separately notified by the TierPaid payout email when they earn) + try { + const buyer = await chain.member(ev.buyerId); + if (buyer && buyer.sponsorId) { + const sp = await accounts.byMemberId(buyer.sponsorId); + if (sp && sp.email) { + const bn = await memberLabel(ev.buyerId); // @username at a glance (Marty, 2026-09-12) + const usd = ('$' + (ev.priceCents / 100).toFixed(2)).replace(/\.00$/, ''); + const qual = ev.priceCents >= 2000 + ? ' This is a $20+ purchase, so it counts toward your qualification.' + : ' (Purchases under $20 do not count toward qualification.)'; + mailer.send(sp.email, bn + ' just bought a ' + usd + ' ad package', + 'Your referral ' + bn + ' (member #' + ev.buyerId + ') just purchased a package (' + usd + ' — ' + ev.creditAmount + ' credits).' + qual + '\n\n' + + 'See your team and the live ledger: https://linkspin-test.saasy.top/my\n\nLinkSpin').catch(() => {}); + } + } + } catch (e) {} + } + else if (ev.type === 'TierPaid') { + const pct = PCT[ev.tier] || 0; const pur = purchaseOf(ev.tx); + let buyer = 'a member on your level ' + ev.tier; try { buyer = await memberLabel(ev.buyerId); } catch (e) {} + const pol = weiToPol(ev.amountWei); + await tell(ev.recipientId, 'You just got paid ' + pol + ' POL on LinkSpin', + buyer + ' just bought an ad package on your level ' + ev.tier + (pur ? ' ($' + (pur.priceCents / 100).toFixed(2).replace(/\.00$/, '') + ')' : '') + '. Your ' + pct + ' percent share, ' + pol + ' POL' + usdShare(pur, pct) + ', landed in your wallet in the same transaction' + (ev.hops ? ', passed up to you because someone between you was not qualified for this level' : '') + '.\n\n' + + 'Transaction: ' + txUrlOf(ev.tx) + '\n\nYour dashboard: https://linkspin-test.saasy.top/my'); + } + else if (ev.type === 'AwardPaid') await tell(ev.toId, 'You just got paid ' + weiToPol(ev.amountWei) + ' POL on LinkSpin', weiToPol(ev.amountWei) + ' POL just landed in your wallet.\n\nTransaction: ' + txUrlOf(ev.tx) + '\n\nYour dashboard: https://linkspin-test.saasy.top/my'); + else if (ev.type === 'PassedUp' && ev.reason === 'send-failed') { + // the member WAS qualified but their wallet rejected the POL (usually a smart-contract + // wallet that needs more than the capped gas): tell them and the admin, loudly + await tell(ev.skippedId, 'Your wallet rejected a payout on LinkSpin', 'A level-' + ev.tier + ' payout tried to reach your linked wallet and the wallet refused the transfer, so it passed to the next qualified member. This happens with some smart-contract wallets. Link a regular wallet address (MetaMask, SafePal, Phantom) on the Wallet tab so the next payout lands.\n\nTransaction: ' + txUrlOf(ev.tx)); + if (ADMIN_EMAIL) mailer.send(ADMIN_EMAIL, 'LinkSpin: payout send-failed for member #' + ev.skippedId, 'A level-' + ev.tier + ' payout to member #' + ev.skippedId + ' failed at the wallet (send-failed) and passed up. Tx: ' + ev.tx + '\n\nThe member has been emailed to link a regular wallet.').catch(() => {}); + } + else if (ev.type === 'PassedUp') { + // Marty (2026-09-15): a member who missed a payout because they were not qualified must be told + // exactly what they missed: who bought, the share in POL and dollars, and how to close the gap. + const pct = PCT[ev.tier] || 0; const pur = purchaseOf(ev.tx); + let pol = '', buyer = 'a member on your level ' + ev.tier; + try { if (pur) pol = weiToPol((BigInt(pur.paidWei) * BigInt(pct) / 100n).toString()); buyer = await memberLabel(ev.buyerId); } catch (e) {} + let bc = 0; try { bc = (await chain.member(ev.skippedId)).buyerCount || 0; } catch (e) {} + const need = ev.tier === 3 ? 5 : 2, short = Math.max(0, need - bc); + const what = pol ? pol + ' POL' + usdShare(pur, pct) : 'a level-' + ev.tier + ' payout'; + await tell(ev.skippedId, 'You missed ' + (pol ? pol + ' POL' : 'a payout') + ' on LinkSpin', + buyer + ' just bought an ad package on your level ' + ev.tier + '. Your share was ' + what + ', and it passed you by because level ' + ev.tier + ' is not open on your account yet. The contract paid it to the next qualified person above you.\n\n' + + 'Level ' + ev.tier + ' opens at ' + need + ' qualifying buyers (people you referred who bought a $20 or larger package). You have ' + bc + (short ? ', so you are ' + short + ' buyer' + (short === 1 ? '' : 's') + ' away.' : '.') + '\n\n' + + 'Two ways to close the gap: bring ' + (short || 1) + ' more buyer' + (short === 1 ? '' : 's') + ' from your My line page, or use Qualified Start under Buy packages to be your own buyer today. Every package on level ' + ev.tier + ' pays you ' + pct + ' percent once it is open, and the next one is coming whether you are ready or not.\n\n' + + 'Transaction: ' + txUrlOf(ev.tx) + '\n\nYour dashboard: https://linkspin-test.saasy.top/my'); + } +} +// payment-proof Telegram feed (same pattern as the RM Circle proof channel): one compact +// line per event, admin-configured under Settings > Site (telegramBotToken, telegramChatId, +// optional telegramTopicId, telegramEvents = payouts | payouts+purchases | all, telegramCtaUrl) +// A second target, telegramEchoChatId + telegramEchoTopicId (+ telegramEchoEvents), echoes +// the same lines into a shared cross-program payments topic that RM Circle also posts to, +// so every line there carries the program name. +async function telegramOnEvent(ev) { + const sc = siteConfig(); + if (!sc.telegramBotToken || !ev) return; + if (!sc.telegramChatId && !sc.telegramEchoChatId) return; + const names = await accounts.namesForMembers([ev.recipientId, ev.buyerId, ev.sponsorId, ev.newBuyerId, ev.id].filter(Boolean)).catch(() => ({})); + const who = id => '#' + id + (names[id] ? ' @' + names[id] : ''); + const cc = chain.getConfig(); + const tx = (cc.explorer ? cc.explorer.replace(/\/+$/, '') : 'https://polygonscan.com') + '/tx/' + ev.tx; + const build = (mode) => { + let line = null; + if (ev.type === 'TierPaid') line = '\u{1F4B8} Level ' + ev.tier + ' payout: ' + weiToPol(ev.amountWei) + ' POL \u2192 ' + who(ev.recipientId); + else if (ev.type === 'BuyerCounted' && mode !== 'payouts') line = '\u2B50 ' + who(ev.sponsorId) + ' now has ' + ev.newCount + ' qualifying buyer' + (ev.newCount === 1 ? '' : 's') + (ev.newCount === 2 ? ' \u00b7 level 2 open' : ev.newCount === 5 ? ' \u00b7 level 3 open' : ''); + else if (ev.type === 'Purchase' && mode !== 'payouts') line = '\u{1F9FE} ' + who(ev.buyerId) + ' bought a $' + Math.round(ev.priceCents / 100) + ' package'; + else if (ev.type === 'AwardPaid') line = '\u{1F4B8} Award payout: ' + weiToPol(ev.amountWei) + ' POL \u2192 ' + who(ev.toId); + else if (ev.type === 'MemberActivated' && mode === 'all') line = '\u{1F91D} ' + who(ev.id) + ' switched on payouts'; + if (!line) return null; + return line + ' \u00b7 verify' + (sc.telegramCtaUrl ? '\nJoin free' : ''); + }; + if (sc.telegramChatId) { const t = build(String(sc.telegramEvents || 'payouts')); if (t) await telegramSend(sc.telegramChatId, t, sc.telegramTopicId); } + if (sc.telegramEchoChatId) { const t = build(String(sc.telegramEchoEvents || 'payouts')); if (t) await telegramSend(sc.telegramEchoChatId, '\u{1F7E0} LinkSpin \u00b7 ' + t, sc.telegramEchoTopicId); } +} +// holding-tank arrivals -> one digest line in the shared payments topic (Marty, 2026-09-12): who is +// waiting for a sponsor, by username, so builders go adopt them. Runs every 15 min, posts only +// when someone new landed since the last check. +async function tankNotifyTick() { + const sc = siteConfig(); + if (!sc.telegramBotToken || !sc.telegramEchoChatId) return; + const f = path.join(DATA_DIR, 'tank-notify.json'); + let st = { last: 0 }; try { st = JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) {} + const since = st.last || (Date.now() - 24 * 3600 * 1000); + const fresh = (await tank.waiting()).filter(w => (w.joined || 0) > since); + st.last = Date.now(); fs.writeFileSync(f, JSON.stringify(st)); + if (!fresh.length) return; + const named = fresh.filter(w => w.username).map(w => '@' + w.username); + const who = named.length ? ': ' + named.slice(0, 8).join(', ') + (named.length > 8 ? ' and ' + (named.length - 8) + ' more' : '') : ''; + const text = '\u{1FAA3} LinkSpin \u00b7 ' + fresh.length + ' new member' + (fresh.length === 1 ? '' : 's') + ' waiting for a sponsor in the holding tank' + who + + '\nAdopt from My line \u203a Holding tank: linkspin-test.saasy.top/my'; + await telegramSend(sc.telegramEchoChatId, text, sc.telegramEchoTopicId); +} +// one sendMessage call; never throws, never logs the token +async function telegramSend(chatId, text, threadId) { + const sc = siteConfig(); + if (!sc.telegramBotToken || !chatId) return; + const body = JSON.stringify(Object.assign({ chat_id: chatId, text, parse_mode: 'HTML', disable_web_page_preview: true }, threadId ? { message_thread_id: Number(threadId) } : {})); + await new Promise((resolve) => { + const rq = https.request({ hostname: 'api.telegram.org', path: '/bot' + sc.telegramBotToken + '/sendMessage', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 10000 }, r => { r.resume(); r.on('end', resolve); }); + rq.on('error', () => resolve()); rq.on('timeout', () => { rq.destroy(); resolve(); }); rq.end(body); + }); +} + +// photo post (multipart) for the achievement badges (Marty, 2026-09-13); same bot, sendPhoto only +async function telegramSendPhoto(chatId, jpeg, caption, threadId) { + const sc = siteConfig(); + if (!sc.telegramBotToken || !chatId || !jpeg) return false; + const B = '----iapbadge' + crypto.randomBytes(8).toString('hex'); + const field = (n, v) => Buffer.from('--' + B + '\r\nContent-Disposition: form-data; name="' + n + '"\r\n\r\n' + v + '\r\n'); + const parts = [field('chat_id', String(chatId)), field('caption', caption), field('parse_mode', 'HTML')]; + if (threadId) parts.push(field('message_thread_id', String(Number(threadId)))); + parts.push(Buffer.from('--' + B + '\r\nContent-Disposition: form-data; name="photo"; filename="badge.jpg"\r\nContent-Type: image/jpeg\r\n\r\n'), jpeg, Buffer.from('\r\n--' + B + '--\r\n')); + const body = Buffer.concat(parts); + return new Promise((resolve) => { + const rq = https.request({ hostname: 'api.telegram.org', path: '/bot' + sc.telegramBotToken + '/sendPhoto', method: 'POST', headers: { 'Content-Type': 'multipart/form-data; boundary=' + B, 'Content-Length': body.length }, timeout: 20000 }, res => { + let out = ''; res.on('data', c => out += c); res.on('end', () => resolve(res.statusCode === 200)); + }); + rq.on('error', () => resolve(false)); rq.on('timeout', () => { rq.destroy(); resolve(false); }); rq.end(body); + }); +} +const BADGE_META = { payouts: ['Spark', 'payouts switched on'], firstBuyer: ['Surge', 'first qualifying buyer'], level2: ['Circuit', 'two qualifying buyers, level 2 open'], level3: ['Nexus', 'five qualifying buyers, fully qualified'] }; +const BADGE_LOG = () => path.join(DATA_DIR, 'badge-posts.json'); +function badgeLog() { try { return JSON.parse(fs.readFileSync(BADGE_LOG(), 'utf8')); } catch (e) { return {}; } } + +// ---- live feed (SSE) ---- +const feedClients = new Set(); +function pushFeed(ev) { + const line = 'data: ' + JSON.stringify(ev) + '\n\n'; + for (const res of feedClients) { try { res.write(line); } catch (e) { feedClients.delete(res); } } +} + +// ---- short link domains (LinkSpin): each domain in siteConfig().linkDomains is a small real site ---- +function reqHost(req) { return String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim().toLowerCase().replace(/:\d+$/, '').replace(/^www\./, ''); } +function isLinkHost(req) { const h = reqHost(req); if (!h) return false; return String(siteConfig().linkDomains || '').split(',').map(x => x.trim().toLowerCase()).filter(Boolean).includes(h); } +function linkSitePage(host, page) { + const esc = t => String(t).replace(/&/g, '&').replace(/' + H + '

      This domain is a link forwarding service operated by ' + brand + ', a member advertising network. Links on this domain forward visitors in one step to pages published by our members and their partners.

      We do not host the pages you land on, and we do not collect payment information here. If a link forwarded you somewhere you did not expect, or to content that looks unsafe, tell us on the report page and we will pull the link.

      What we record

      When a link is followed we record the day, the country, the referring site, the device type and a one-day anonymous visitor token. We do not store your IP address or any personal data. Details are in the privacy policy.

      ', + disclosure: '

      Affiliate and advertising disclosure

      Links forwarded through ' + H + ' may be affiliate links or paid advertisements. The member who published a link may earn a commission or advertising credit if you buy something or sign up on the page you land on. This never changes the price you pay.

      ' + brand + ' itself is an advertising platform. Members buy ad packages and earn commissions when people they referred buy ad packages. No income is guaranteed to anyone; results depend on effort, and any earnings examples on member pages are illustrations, not promises.

      The pages you are forwarded to belong to their publishers. We are not responsible for their claims, and we remove links that are reported and found to violate our terms.

      ', + terms: '

      Terms of use

      By following a link on ' + H + ' you agree to these terms.

      The service

      ' + H + ' forwards visitors to destinations chosen by ' + brand + ' members. We provide the forwarding only. We do not endorse, verify or control the destination pages, and we are not a party to any transaction you make there.

      Acceptable use

      Members may not forward visitors to content that is illegal, deceptive, malicious, adult, or that impersonates another business. Links that violate this are removed and the member\'s account is closed. Automated access to this domain is not permitted except by search engines.

      No warranty

      The service is provided as is. We do not guarantee that any link will be available, and we are not liable for the content, availability or conduct of destination sites.

      Contact

      Questions about these terms: linkspin.co.

      ', + privacy: '

      Privacy policy

      This policy covers ' + H + ' and the link forwarding service on it.

      What we collect

      When you follow a link we record the date, your country (derived from your network address and then discarded), the site that referred you, whether you are on a phone or a computer, and an anonymous visitor token that changes every day and cannot be traced back to you. We do not store your IP address, your name, your email address or any account information on this domain.

      Cookies

      This domain sets no cookies. The page you are forwarded to has its own privacy policy and may set its own.

      Why we collect it

      So members can see how many people followed their links and from where. Counts are shown to the member who published the link, in aggregate, and to nobody else.

      Retention

      Link statistics are kept for up to thirteen months and then deleted.

      Your rights

      Because we hold no personal data on this domain there is nothing to access or delete here. For anything involving a ' + brand + ' member account, contact us through linkspin.co.

      ', + abuse: '

      Report a link

      If a link on ' + H + ' forwarded you to a page that is deceptive, unsafe, adult, or impersonates a business, tell us. We review every report and pull the link first, then ask questions.

      Send the full link you clicked (it starts with https://' + H + '/r/) and what you saw, to the support contact on linkspin.co. Reports are usually handled the same day.

      ', + gone: '

      That link is not active

      The link you followed has been removed or has expired. Nothing was recorded. If you were expecting a specific page, ask the person who shared the link for a fresh one.

      ' + }; + return '' + H + '
      ' + nav + (bodies[page] || bodies.gone) + '
      '; +} + +// ---- server ---- +const server = http.createServer(async (req, res) => { + try { + const u = new URL(req.url, 'http://x'); + const p = u.pathname; + + // -- the rotator redirect: one hop, recorded, on any host (LinkSpin) + let rm = /^\/r\/([a-z0-9]{4,12})$/i.exec(p); + if (rm && (req.method === 'GET' || req.method === 'HEAD')) { + const hit = await rotator.resolve(rm[1], req); + if (hit && hit.url) { res.writeHead(302, { Location: hit.url, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer-when-downgrade' }); return res.end(); } + res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(linkSitePage(reqHost(req), 'gone')); + } + // -- short link domains: a real small site (home, disclosure, terms, privacy, abuse) so the domain never looks parked + if (isLinkHost(req)) { + const page = { '/': 'home', '/disclosure': 'disclosure', '/terms': 'terms', '/privacy': 'privacy', '/abuse': 'abuse', '/robots.txt': 'robots' }[p]; + if (!page) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(linkSitePage(reqHost(req), 'gone')); } + if (page === 'robots') { res.writeHead(200, { 'Content-Type': 'text/plain' }); return res.end('User-agent: *\nDisallow: /r/\n'); } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' }); return res.end(linkSitePage(reqHost(req), page)); + } + + // -- traffic log: public page views by referring domain (admin > Traffic) + if (req.method === 'GET' && (TRAFFIC_PAGES.has(p) || /^\/(join|from|wall|blog)\/[^/]+$/.test(p))) traffic.hit(p.startsWith('/blog/') ? '/blog/*' : p, req.headers.referer, req.headers['user-agent']); + // -- join links: /join/ — LAST-touch cookie (Marty, + // 2026-09-10): the link a visitor opened most recently is the sponsor shown + // and used, and it locks the moment the account is created (accounts.ensure + // never changes an existing account's sponsor; the contract binds the buyer at + // their first purchase). Codes resolve LATE (at buy time) to whatever chain id + // the referrer has by then, so free members refer from day one. + let m = /^\/join\/([A-Za-z0-9_]{1,20})$/.exec(p); + if (m && (req.method === 'GET' || req.method === 'HEAD')) { + // lead-capture page: email first, wallet later. ?v= picks the hook + // copy and is remembered so the account records which angle converted. + // partner placement: linkspin-test.saasy.top/join/company (or /top) places the visitor directly under + // member #1, the company position, with no sponsor in between (Marty, 2026-09-12 partner kit) + const tok = JOIN_ALIASES[m[1].toLowerCase()] || m[1].toLowerCase(); + const cookies = parseCookies(req); + const angle = String(u.searchParams.get('v') || '').toLowerCase(); + const ang = JOIN_ANGLES[angle] || null; + if (req.method === 'GET') coach.recordView(tok, ang ? angle : '', req.headers.referer); // link stats per angle + source + const cookieTail = `; Path=/; SameSite=Lax; Max-Age=${30 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`; // 30 days: whoever brings them back gets the credit + const set = []; + set.push('iap.sponsor=' + tok + cookieTail); // last touch wins + const promo = promos.norm(u.searchParams.get('promo')); if (promo) set.push('iap.promo=' + promo + cookieTail); // partner code, redeemed at signup + if (ang) set.push('iap.angle=' + angle + cookieTail); + if (!cookies['iap.ref']) set.push('iap.ref=' + encodeURIComponent(coach.refHost(req.headers.referer)) + cookieTail); // first-touch source + return serveJoinPage(res, tok, ang ? angle : '', ang, set); + } + // -- legacy bridge: /from/faucetwave | /from/tieroneads [?seg=advertiser]. Same squeeze page + // with brand copy, NO sponsor (the sponsor cookie is cleared so they land in the holding + // tank for adoption), the angle remembered so the welcome-back grant fires at signup, and + // a forced first-touch source so link stats / admin can see the legacy arrivals. + m = /^\/from\/(faucetwave|tieroneads)$/.exec(p); + if (m && (req.method === 'GET' || req.method === 'HEAD')) { + const brand = m[1]; + const key = (brand === 'faucetwave' ? 'fw' : 't1') + (String(u.searchParams.get('seg') || '').toLowerCase().startsWith('adv') ? '-adv' : '-earn'); + const cookieTail = `; Path=/; SameSite=Lax; Max-Age=${30 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`; + const src = String(u.searchParams.get('src') || '').toLowerCase().replace(/[^a-z0-9.\-]/g, '').slice(0, 40); // e.g. the old domain's splash page + const set = ['iap.sponsor=; Path=/; SameSite=Lax; Max-Age=0' + (IS_PROD ? '; Secure' : ''), 'iap.angle=' + key + cookieTail, 'iap.ref=' + encodeURIComponent('legacy:' + brand + (src ? ':' + src : '')) + cookieTail]; + return serveJoinPage(res, '', key, JOIN_ANGLES[key], set); + } + if (p === '/unsubscribe' && req.method === 'GET') { + const r = await drip.unsubscribe(u.searchParams.get('e'), u.searchParams.get('t')); + const msg = r.error ? r.error : 'Done. You will not get any more follow-up emails from LinkSpin. Your account is unchanged.'; + res.writeHead(r.error ? 400 : 200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' })); + return res.end('LinkSpin
      LinkSpin

      ' + (r.error ? 'Hmm.' : 'Unsubscribed.') + '

      ' + msg + '

      Member area

      '); + } + + // -- public API + if (p === '/api/config' && req.method === 'GET') { + const c = chain.getConfig(); + // public copy of the site settings: never anything that looks like a credential + const pubSite = {}; + for (const [k, v] of Object.entries(siteConfig())) if (!/secret|token|password|private|apikey|api_key/i.test(k)) pubSite[k] = v; + return json(res, 200, Object.assign({ contract: c.contract, chainId: c.chainId, + chainName: c.chainName, explorer: c.explorer, rpc: c.rpcs[0], + emailAuth: mailer.hasKey() || !IS_PROD }, pubSite)); + } + if (p === '/api/moonpay-url' && req.method === 'GET') { + // Card on-ramp deep link. With MoonPay keys set — PUBLIC key via + // MOONPAY_PUBLIC_KEY env or site config, SECRET key via MOONPAY_SECRET_KEY + // env ONLY (never site config, since /api/config exposes siteConfig) — + // returns a SIGNED checkout URL prefilled with the buyer's own wallet and + // a POL amount; otherwise a generic MoonPay buy page. Zero custody either + // way: MoonPay is merchant of record and the crypto goes straight to the + // buyer's wallet — this site never touches or holds anyone's money. + const addr = (u.searchParams.get('address') || '').trim(); + let pol = Math.round(Number(u.searchParams.get('pol')) || 0); + if (!pol || pol < 30) pol = 30; + if (pol > 100000) pol = 100000; + const pk = (process.env.MOONPAY_PUBLIC_KEY || siteConfig().moonpayPublicKey || '').trim(); + const sk = (process.env.MOONPAY_SECRET_KEY || '').trim(); + if (pk && sk && /^0x[0-9a-fA-F]{40}$/.test(addr)) { + const qs = '?apiKey=' + encodeURIComponent(pk) + '¤cyCode=pol_polygon&walletAddress=' + encodeURIComponent(addr) + '"eCurrencyAmount=' + pol; + const sig = crypto.createHmac('sha256', sk).update(qs).digest('base64'); + return json(res, 200, { url: 'https://buy.moonpay.com/' + qs + '&signature=' + encodeURIComponent(sig), signed: true, pol }); + } + return json(res, 200, { url: 'https://www.moonpay.com/buy/matic', signed: false, pol }); // MoonPay's Polygon page still lives at the old slug; /buy/pol renders their 404 (same fix as RM Circle) + } + if (p === '/api/catalog' && req.method === 'GET') { + // five live quotes per call: serve a 20-second cache so pages that load the + // ladder (join, home, buy) are not waiting on the RPC every time + if (!catalogCache.at || Date.now() - catalogCache.at > 20000) { + try { catalogCache.products = await chain.catalog(); catalogCache.at = Date.now(); } + catch (e) { if (!catalogCache.products) throw e; } + } + return json(res, 200, { products: catalogCache.products }); + } + if (p === '/api/feed' && req.method === 'GET') { + return json(res, 200, { events: await attachNames(chain.recentEvents(Number(u.searchParams.get('n')) || 100)) }); + } + if (p === '/api/feed/live' && req.method === 'GET') { + res.writeHead(200, baseHeaders({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' })); + res.write(': connected\n\n'); + feedClients.add(res); + req.on('close', () => feedClients.delete(res)); + return; + } + m = /^\/api\/tx\/(0x[0-9a-fA-F]{64})$/.exec(p); + if (m && req.method === 'GET') { + // tx relay so the browser never talks to the RPC directly (CSP stays 'self'). + // Serves both the wallet's waitTx poll (found/status) and the built-in + // /tx/ viewer (full details + decoded events). + try { + const r = await chain.rpc('eth_getTransactionReceipt', [m[1]]); + if (!r) return json(res, 200, { found: false }); + const out = { found: true, status: r.status, blockNumber: r.blockNumber, gasUsed: r.gasUsed }; + try { + const t = await chain.rpc('eth_getTransactionByHash', [m[1]]); + if (t) { out.from = t.from; out.to = t.to; out.valueWei = BigInt(t.value || '0x0').toString(); } + } catch (e) {} + try { + const blk = await chain.rpc('eth_getBlockByNumber', [r.blockNumber, false]); + if (blk) out.ts = blk.timestamp; + } catch (e) {} + try { + out.events = await attachNames((r.logs || []).map(chain.decodeLog).filter(Boolean) + .map(ev => Object.assign(ev, { tx: m[1] }))); + } catch (e) { out.events = []; } + return json(res, 200, out); + } catch (e) { return json(res, 200, { found: false, rpcError: true }); } + } + if (p === '/api/sponsor' && req.method === 'GET') { + // The account's stored sponsor is authoritative — it persists across + // devices, cleared cookies, and return visits. Fall back to the first-touch + // cookie only for anonymous visitors with no account sponsor yet. (Reading + // the cookie alone was orphaning buyers to root when the cookie was absent.) + const s = await auth.fromRequest(req); + const acct = s && s.email ? await accounts.byEmail(s.email) : null; + const tok = (acct && acct.sponsorRef) || parseCookies(req)['iap.sponsor'] || ''; + const spd = await resolveSponsorDetailed(tok); let sponsorId = spd.id; + // LinkSpin carry-over: a known sponsor with a wallet is activated on this contract by the engine + // before the member's first transaction; one with no wallet opens the claim window (the buy waits) + let claim = null; + if (acct && !sponsorId) { + try { + const cr = await carry.resolveForChain(acct, spd); + if (cr && cr.sponsorId) { sponsorId = cr.sponsorId; spd.reason = 'ok'; } + else if (cr && cr.hold) { claim = cr.claim; } + } catch (e) {} + } + if (claim) return json(res, 200, { ref: tok, sponsorId: 0, sponsorBlocked: 'claim', claim, sponsorName: spd.name || null, invited: true, name: null, avatarUrl: null, own: false, bio: null, cobrand: false }); + let sponsorBlocked = null; // set when the account itself names a sponsor that cannot be paid right now: the client refuses the transaction + if (acct && acct.sponsorRef && !sponsorId && spd.reason !== 'none') { sponsorBlocked = spd.reason; sponsorBlockedAlert(acct.email, Object.assign({ tok }, spd)); } + if (sponsorId && await payoutChainBlocked(sponsorId, 2)) { console.log('sponsor routed away from no-payout chain', sponsorId); sponsorId = 0; } + // orphan fallback: an unresolvable/absent sponsor (dead link, no link) lands + // the new member under the configured catch position (#1) instead of root + if (!sponsorId && (!acct || acct.memberId !== (Number(siteConfig().defaultSponsorId) || 1))) sponsorId = Number(siteConfig().defaultSponsorId) || 1; + let name = null, avatarUrl = null, own = false; + // the join page names the owner of the LINK it was opened with (?ref=token), not the visitor's own + // sponsor: a member previewing their own invite page was seeing their upline's name (Jim, 2026-09-13) + let showTok = String(u.searchParams.get('ref') || '').trim().toLowerCase(); + if (showTok && JOIN_ALIASES[showTok]) showTok = JOIN_ALIASES[showTok]; + const nameTok = showTok || tok; + if (nameTok) { + const t = nameTok.toLowerCase(); + let a = await accounts.byCode(t); if (!a) a = await accounts.byUsername(t); + if (!a && /^\d+$/.test(nameTok)) a = await accounts.byMemberId(Number(nameTok)); + if (a) { name = a.username ? '@' + a.username : (a.memberId ? 'member #' + a.memberId : null); avatarUrl = a.avatarUrl || null; own = !!(acct && a.email === acct.email); var bio = null, cobrand = false; try { cobrand = (await ads.milestonesOf(a.email)).includes('level3'); if (cobrand) bio = a.bio ? String(a.bio).slice(0, 220) : null; } catch (e) {} } + } + return json(res, 200, { ref: tok, sponsorId, sponsorBlocked, sponsorName: spd.name || null, invited: !!(tok || showTok), name, avatarUrl, own, bio: typeof bio === 'undefined' ? null : bio, cobrand: typeof cobrand === 'undefined' ? false : cobrand }); + } + if (p === '/api/stats' && req.method === 'GET') { + let members = 0; try { members = await chain.memberCount(); } catch (e) {} + return json(res, 200, Object.assign({ onchainMembers: members, siteAccounts: await accounts.count() }, chain.totals())); + } + + // -- 24/7 assistant + if (p === '/api/chat' && req.method === 'POST') { + const ip = req.socket.remoteAddress || 'x'; + if (chatLimited(ip)) return json(res, 429, { error: 'Give it a minute, then ask again.' }); + const b = await readBody(req); + const r = await chatbot.answer(b.message, b.history); + return json(res, r.error ? 400 : 200, r); + } + + // -- accounts: email + password is the normal join path (wallet comes + // out only at purchase / payout-activation time and gets linked then) + if (p === '/api/signup' && req.method === 'POST') { + const b = await readBody(req); + const ref = parseCookies(req)['iap.sponsor'] || ''; // last-touch attribution, locked at account creation + const r = await accounts.signup(b.email, b.password, ref); + if (r.error) return json(res, 400, r); + // sponsor is notified once the new member picks a username (onboarding), + // so the email can name them — see /api/my/profile + if (b.newsletter) sendy.subscribe(r.account.email, r.account.username || '').catch(() => {}); // pre-checked opt-in, silent + const token = await auth.mintSession({ email: r.account.email }); + return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) }); + } + if (p === '/api/login' && req.method === 'POST') { + const b = await readBody(req); + const r = await accounts.login(b.email, b.password); + if (r.error) return json(res, 400, r); + let memberId = 0; + if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (e) {} } + const token = await auth.mintSession({ email: r.account.email, address: r.account.address, memberId }); + return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) }); + } + + // -- passwordless: email code sign-in (signup and login are the same act) + if (p === '/api/auth/email/start' && req.method === 'POST') { + const b = await readBody(req); + const e = String(b.email || '').trim().toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return json(res, 400, { error: 'That email address does not look right.' }); + const prev = emailCodes.get(e); + if (prev && Date.now() < prev.nextAt) { console.log('signup-guard cooldown', clientIp(req), e.replace(/^(.).*(@.*)$/, '$1***$2')); return json(res, 429, { error: 'Code already sent. Give it a minute, then try again.' }); } + const guard = codeGuard(req, b); // honeypot, form age, per-IP + global limits, icon check once limited + if (guard) return json(res, guard.status, guard.body); + const code = String(Math.floor(100000 + Math.random() * 900000)); + emailCodes.set(e, { code, exp: Date.now() + 15 * 60 * 1000, tries: 0, nextAt: Date.now() + 60 * 1000 }); + if (mailer.hasKey()) { + try { await mailer.sendCode(e, code); } catch (err) { + console.error('sendCode failed', err.message); + return json(res, 502, { error: 'Could not send the email. Try again in a minute.' }); + } + return json(res, 200, { ok: true, sent: true }); + } + if (!IS_PROD) return json(res, 200, { ok: true, sent: false, devCode: code }); + return json(res, 503, { error: 'Email sign-in is not configured yet.' }); + } + if (p === '/api/auth/email/verify' && req.method === 'POST') { + const b = await readBody(req); + const e = String(b.email || '').trim().toLowerCase(); + const rec = emailCodes.get(e); + if (!rec || rec.exp < Date.now()) return json(res, 400, { error: 'Code expired. Request a fresh one.' }); + rec.tries += 1; + if (rec.tries > 6) { emailCodes.delete(e); return json(res, 400, { error: 'Too many tries. Request a fresh code.' }); } + if (String(b.code || '').trim() !== rec.code) return json(res, 400, { error: 'That code does not match.' }); + emailCodes.delete(e); + const ref = parseCookies(req)['iap.sponsor'] || ''; + const via = parseCookies(req)['iap.angle'] || ''; + const joinedRef = decodeURIComponent(parseCookies(req)['iap.ref'] || '') || null; + const r = await accounts.ensure(e, ref, via, joinedRef); // first touch wins; existing accounts unchanged + if (r.error) return json(res, 400, r); + if (r.created) carry.onJoin(r.account, !!ref).catch(() => {}); // network sponsor carries over when no link was used (LinkSpin) + // the lead is in the door: queue the getting-started sequence (opt-in box is pre-checked on both forms) + if (r.created && (b.followups || b.newsletter)) drip.enqueue(e, ref, via).catch(() => {}); + // a wallet-only session (signed with a wallet, no account) finishing setup: + // adopt that wallet into the email account so member #, purchases and + // payouts stay attached, then retire the wallet-only session + const prior = await auth.fromRequest(req); + if (prior && prior.address && !prior.email) { + const lr = await accounts.linkWallet(e, prior.address); + if (lr.error) return json(res, 400, lr); + r.account = lr.account || await accounts.byEmail(e); + await auth.logout(req); + } + if (r.created) { sendWelcome(e, ref).catch(() => {}); } // sponsor notified at username set (/api/my/profile) + // partner promo code carried on the join link: redeem once per account (ignored if invalid/used) + try { + const pc = parseCookies(req)['iap.promo']; + if (pc) { const g = await promos.redeem(pc, e, 'link'); if (g.ok) { if (g.funder && g.funder !== e) { if (await ads.spendEarned(g.funder, g.credits)) { await ads.addEarned(e, g.credits); console.log('promo redeemed (member-funded)', g.code, g.credits, e, 'by', g.funder); } else console.log('promo funder short', g.code, g.funder); } else { await ads.addEarned(e, g.credits); console.log('promo redeemed', g.code, g.credits, e); } } } + } catch (err) { console.error('promo redeem', err.message); } + // legacy bridge: a listed former Faucet Wave / Tier One Ads member gets welcome-back credits once + if (r.created && /^(fw|t1)-(adv|earn)$/.test(via)) { + try { const g = legacy.grant(e, siteConfig()); if (g) { await ads.addEarned(e, g.credits); console.log('legacy grant', g.brand, g.seg, g.credits, e); } } + catch (err) { console.error('legacy grant', err.message); } + } + if (r.created && b.newsletter) sendy.subscribe(r.account.email, r.account.username || '').catch(() => {}); // pre-checked opt-in, silent, new joins only + let memberId = 0; + if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (err) {} } + const token = await auth.mintSession({ email: r.account.email, address: r.account.address, memberId }); + return json(res, 200, { ok: true, account: r.account, created: !!r.created }, { 'Set-Cookie': auth.sessionCookie(token) }); + } + + // -- wallet auth: link-to-account when an email session exists, or + // wallet-first sign-in (no UI door since 2026-09-09; a wallet-only session + // is walked to the email card, which adopts the wallet on verify) + if (p === '/api/auth/challenge' && req.method === 'POST') { + const b = await readBody(req); + const r = auth.makeChallenge(b.address); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/auth/verify' && req.method === 'POST') { + const b = await readBody(req); + const r = await auth.verifyChallenge(b.address, b.signature); + if (r.error) return json(res, 400, r); + let memberId = 0; + try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {} + const s = await auth.fromRequest(req); + if (s && s.email && b.asPosition) { + // Qualified Start: a second (third…) wallet on the same account. It becomes + // its own on-chain member under this member's id when it buys; the session + // stays on the main wallet. + // policy (Marty, 2026-09-10): positions exist to qualify, so at most five per account, and a + // wallet that is already registered on-chain under anything other than this account's main + // member is refused (a position-under-position chain would recapture 70% of a self-buy) + const MAX_POS = 5; + const have = await accounts.positions(s.email); + if (!have.find(p => p.address === r.address.toLowerCase()) && have.length >= MAX_POS) + return json(res, 400, { error: 'You can link up to ' + MAX_POS + ' positions, which is everything Qualified Start needs. Once qualified, buy from your main wallet so your sponsor is paid in full.' }); + if (memberId) { + const mainId = await auth.refreshMemberId(s); + let mm = null; try { mm = await chain.member(memberId); } catch (e) {} + if (mm && mainId && mm.sponsorId !== mainId) + return json(res, 400, { error: 'That wallet is already registered on-chain under ' + (mm.sponsorId ? 'member #' + mm.sponsorId : 'no sponsor') + ', not under your main member #' + mainId + '. Only positions registered directly under you can be linked.' }); + } + const pr = await accounts.addPosition(s.email, r.address); + if (pr.error) return json(res, 400, pr); + if (memberId) await accounts.setPositionMember(r.address, memberId); + return json(res, 200, { ok: true, position: true, address: r.address, memberId }); + } + if (s && s.email) { + const lr = await accounts.linkWallet(s.email, r.address); + if (lr.error) return json(res, 400, lr); + await auth.updateSession(s.token, { address: r.address, memberId }); + return json(res, 200, { ok: true, linked: true, address: r.address, memberId }); + } + const acct = await accounts.byAddress(r.address); + if (!acct && await accounts.positionOwner(r.address)) + return json(res, 400, { error: 'That wallet is a linked position on an account. Sign in with that account\'s email instead.' }); + const token = await auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId }); + return json(res, 200, { ok: true, address: r.address, memberId }, + { 'Set-Cookie': auth.sessionCookie(token) }); + } + if (p === '/api/auth/logout' && req.method === 'POST') { + await auth.logout(req); + return json(res, 200, { ok: true }, { 'Set-Cookie': auth.clearCookie() }); + } + if (p === '/api/gas' && req.method === 'GET') { + try { return json(res, 200, await chain.suggestedFees()); } + catch (e) { return json(res, 200, {}); } + } + + if (p === '/api/me' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s) return json(res, 200, { signedIn: false }); + const memberId = await auth.refreshMemberId(s); + const acct = (s.email && await accounts.byEmail(s.email)) || (s.address && await accounts.byAddress(s.address)) || null; + const spdMe = await resolveSponsorDetailed((acct && acct.sponsorRef) || parseCookies(req)['iap.sponsor']); let sponsorId = spdMe.id; + const sponsorBlocked = (acct && acct.sponsorRef && !sponsorId && spdMe.reason !== 'none') ? spdMe.reason : null; + if (sponsorId && await payoutChainBlocked(sponsorId, 2)) sponsorId = 0; + const _defSpon = Number(siteConfig().defaultSponsorId) || 1; + if (!sponsorId && memberId !== _defSpon) sponsorId = _defSpon; // orphan fallback → #1 (never self-sponsor) + if (memberId && acct && acct.memberId !== memberId) accounts.setMemberId(acct.email, memberId).catch(() => {}); + const out = { signedIn: true, sponsorBlocked, sponsorName: spdMe.name || null, email: s.email || (acct && acct.email) || null, + address: s.address || (acct && acct.address) || null, memberId, + username: (acct && acct.username) || null, + refCode: (acct && acct.code) || null, sponsorId, + // profile + line-banner fields so the Profile pane repopulates on reload (were being saved but not returned) + avatarUrl: (acct && acct.avatarUrl) || null, bio: (acct && acct.bio) || null, + socials: (acct && acct.socials) || null, + wallOffers: parseWallOffers(acct), + lineBannerUrl: (acct && acct.lineBannerUrl) || null, lineTargetUrl: (acct && acct.lineTargetUrl) || null }; + if (memberId) { + try { + const mm = await chain.member(memberId); + out.buyerCount = mm.buyerCount; + out.onchainSponsorId = mm.sponsorId; + const bal = await ads.balances((await myMemberIds(s)).ids, s.email); + out.credits = bal.total; out.creditedCredits = bal.credited; out.earnedCredits = bal.earned; out.inCampaigns = bal.inCampaigns; out.availableCredits = bal.available; + } catch (e) { out.chainReadError = true; } + } + return json(res, 200, out); + } + + if (p === '/api/my/dashboard' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s) return json(res, 401, { error: 'Sign in first.' }); + const memberId = await auth.refreshMemberId(s); + const acct = (s.email && await accounts.byEmail(s.email)) || (s.address && await accounts.byAddress(s.address)) || null; + if (memberId && acct && acct.memberId !== memberId) accounts.setMemberId(acct.email, memberId).catch(() => {}); + let tankWaiting = null; // top of every Overview: people waiting for a sponsor (Marty, 2026-09-12) + try { + if (!tankWaitCache || Date.now() - tankWaitCache.ts > 60000) tankWaitCache = { ts: Date.now(), list: await tank.waiting() }; + const tw = tankWaitCache.list; + tankWaiting = { count: tw.length, names: tw.slice(0, 6).map(w => w.name), eligible: !!(await tank.eligibility(s.email)).ok }; + } catch (e) {} + const sc0 = siteConfig(); + const out = { memberId, tankWaiting, email: s.email || (acct && acct.email) || null, + pipeline: { live: pipeline.visible(sc0.pipelineMode, s.email || (acct && acct.email), ADMIN_EMAIL), mode: sc0.pipelineMode || 'off', eta: sc0.pipelineEta || '' }, + address: s.address || (acct && acct.address) || null, + username: (acct && acct.username) || null, + refCode: (acct && acct.code) || null, lineBannerUrl: (acct && acct.lineBannerUrl) || null, /* the launch checklist mark reads it (was 7 of 8 with a banner set, 2026-09-14) */ credits: 0, buyerCount: 0, + earnedWei: '0', earnCount: 0, referrals: [], welcomeCredits: 0 }; + if (out.email) { + // welcome credits unlock via the welcome tour when an upline with a + // line banner exists; members with no tour to walk get them instantly + const welcomed = await ads.welcomeGranted(out.email); + const tour = welcomed ? [] : (await uplineSlides(out.email)).filter(a => a.lineTargetUrl); + if (welcomed || !tour.length) { await ads.grantWelcome(out.email); out.welcomeCredits = ads.rates().welcomeCredits || 0; } // the welcome amount itself; the rest of the earned pool is viewing rewards, milestones and credits we add + 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 }; + } + 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) { + try { + const mm = await chain.member(memberId); + out.buyerCount = mm.buyerCount; + const mine = await myMemberIds(s); + // linked positions are the member's own: their qualifying buyers count toward the account (Jim, 2026-09-14) + out.buyerCountMain = mm.buyerCount; out.buyerCountPositions = 0; + for (const id of mine.ids) if (id && id !== memberId) { try { out.buyerCountPositions += (await chain.member(id)).buyerCount || 0; } catch (e) {} } + out.buyerCountAll = out.buyerCountMain + out.buyerCountPositions; // badges count every position; levels the contract pays #main on depend on buyerCount alone + const bal = await ads.balances(mine.ids, s.email); + out.credits = bal.total; out.creditedCredits = bal.credited; out.earnedCredits = bal.earned; out.inCampaigns = bal.inCampaigns; out.availableCredits = bal.available; + } catch (e) { out.chainReadError = true; } + let earned = 0n, n = 0; + for (const ev of chain.recentEvents(600)) { + if ((ev.type === 'TierPaid' && ev.recipientId === memberId) || (ev.type === 'AwardPaid' && ev.toId === memberId)) { + earned += BigInt(ev.amountWei); n += 1; + } + } + out.earnedWei = earned.toString(); + out.earnCount = n; + } + // achievement milestones (same ladder as the Overview stepper) + one-time credit + // bonuses — computed AFTER buyerCount is read from chain above (else always 0) + { + const bc = out.buyerCountAll != null ? out.buyerCountAll : (out.buyerCount || 0); + const reached = []; + if (out.memberId) reached.push('payouts'); + if (bc >= 1) reached.push('firstBuyer'); + if (bc >= 2) reached.push('level2'); + if (bc >= 5) reached.push('level3'); + try { for (const k of await ads.milestonesOf(out.email)) if (!reached.includes(k)) reached.push(k); } catch (e) {} // a badge once earned stays earned + out.milestonesReached = reached; + if (out.email && reached.length) out.milestonesGranted = await ads.grantMilestones(out.email, reached); + } + // who joined through this member: their invite link uses username when set, + // else code, and the numeric id once on-chain — match all three + const refs = []; + if (acct && acct.code) refs.push(acct.code); + if (acct && acct.username) refs.push(acct.username); + if (memberId) refs.push(String(memberId)); + const joined = await accounts.listByReferrer(refs); + out.referrals = joined.map(r => ({ + name: r.username || r.email.replace(/^(.).*(@.*)$/, '$1***$2'), // username, else privacy mask + joined: r.created, + status: r.address ? 'wallet linked' : 'joined free' + })); + out.isAdmin = !!(ADMIN_EMAIL && out.email && String(out.email).toLowerCase() === ADMIN_EMAIL); // shows the Admin link + out.wallUnlocked = wallUnlockedFor(out.buyerCount || 0); // how many wall positions are the member's own + return json(res, 200, out); + } + // -- linked positions (Qualified Start): list, refresh from chain, unlink + if (p === '/api/my/positions' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const acct = await accounts.byEmail(s.email); + const mainId = await auth.refreshMemberId(s); + const list = await accounts.positions(s.email); + const out = []; + const posIds = [mainId, ...list.map(p => p.memberId)].filter(Boolean); + let balPer = {}, credited = 0; try { const bb = await ads.balances(posIds, s.email); credited = bb.credited; for (const p of bb.per) balPer[p.memberId] = p.avail; } catch (e) {} + for (const pos of list) { + let id = pos.memberId; + if (!id) { try { id = await chain.memberIdByAccount(pos.address); if (id) await accounts.setPositionMember(pos.address, id); } catch (e) {} } + const row = { address: pos.address, memberId: id || 0, buyerCount: 0, counted: false, credits: 0, created: pos.created }; + if (id) { + try { const mm = await chain.member(id); row.buyerCount = mm.buyerCount; row.counted = mm.countedAsBuyer; row.sponsorId = mm.sponsorId; } catch (e) {} + row.credits = balPer[id] != null ? balPer[id] : 0; + row.noPayout = await payoutChainBlocked(id, 3); // linkage only: a buy from here would pay a listed wallet + } + out.push(row); + } + const main = { address: (acct && acct.address) || null, memberId: mainId, credits: 0, buyerCount: 0 }; + if (mainId) { + main.credits = balPer[mainId] != null ? balPer[mainId] : 0; + try { main.buyerCount = (await chain.member(mainId)).buyerCount; } catch (e) {} + main.noPayout = await payoutChainBlocked(mainId, 3); + } + // live POL balances (the link signature proved the wallet is theirs) + a POL/USD rate from the catalog + const bal = async a => { try { return BigInt(await chain.rpc('eth_getBalance', [a, 'latest'])).toString(); } catch (e) { return null; } }; + if (main.address) main.balanceWei = await bal(main.address); + for (const row of out) row.balanceWei = await bal(row.address); + let polUsd = 0; try { const cat = await chain.catalog(); const pk = cat.find(x => x.costWei); if (pk) polUsd = (pk.priceCents / 100) / (Number(BigInt(pk.costWei)) / 1e18); } catch (e) {} + return json(res, 200, { main, positions: out, polUsd, totalCredits: main.credits + out.reduce((n, r) => n + r.credits, 0), credited }); + } + if (p === '/api/my/positions/remove' && 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 r = await accounts.removePosition(s.email, b.address); + return json(res, r.error ? 400 : 200, r); + } + // -- coaching: every direct's ladder rung, stalled flag, and what to say + // -- holding tank: waiting members, my adoptions, adopt, release (pay it forward) + // -- promo code typed on the dashboard + if (p === '/api/my/promo/redeem' && 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 g = await promos.redeem(b.code, s.email, 'dashboard'); + if (g.error) return json(res, 400, g); + await ads.addEarned(s.email, g.credits); + return json(res, 200, { ok: true, credits: g.credits, code: g.code, partner: g.partner }); + } + // -- admin: member card. GET ?q= resolves email / @username / #id / share code / wallet; + // PATCH edits username, sponsor, main wallet or grants credits; DELETE removes a free account (2026-09-13) + if (p === '/api/admin/member' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const q = u.searchParams.get('q') || u.searchParams.get('email') || ''; + const a = await adminMember.resolve(q); + if (!a) return json(res, 404, { error: 'No member matches "' + q.slice(0, 60) + '".' }); + return json(res, 200, await adminMember.view(a.email)); + } + if (p === '/api/admin/member' && req.method === 'PATCH') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + const e = String(b.email || '').trim().toLowerCase(); const acct = e && await accounts.byEmail(e); + if (!acct) return json(res, 404, { error: 'No such member.' }); + if (b.username !== undefined) { + const un = String(b.username || '').trim().toLowerCase(); + if (!/^[a-z0-9_]{3,20}$/.test(un)) return json(res, 400, { error: 'Username: 3 to 20 letters, numbers or underscore.' }); + const r = await accounts.setUsername(e, un); if (r.error) return json(res, 400, r); + console.log('admin username', e, un); + } + if (b.sponsorRef !== undefined) { const r = await accounts.setSponsorRef(e, String(b.sponsorRef || '').trim()); if (r.error) return json(res, 400, r); console.log('admin sponsor', e, String(b.sponsorRef || '').trim()); } + if (b.address !== undefined) { + const a = String(b.address || '').trim().toLowerCase(); + if (a && !/^0x[0-9a-f]{40}$/.test(a)) return json(res, 400, { error: 'That is not a wallet address.' }); + const r = await accounts.adminSetAddress(e, a || null); if (r.error) return json(res, 400, r); + let mid = 0; if (a) { try { mid = Number(await chain.memberIdByAccount(a)) || 0; } catch (x) {} } + await accounts.setMemberId(e, mid); + if (db.enabled()) await db.q('UPDATE sessions SET address=?, member_id=? WHERE email=?', [a || null, mid || null, e]).catch(() => {}); + console.log('admin wallet swap', e, a || '(none)', 'member', mid); + } + if (b.grantCredits !== undefined) { + const n = Math.round(Number(b.grantCredits)); + if (!(n > 0) || n > 100000) return json(res, 400, { error: 'Credits: a whole number from 1 to 100,000.' }); + await ads.addEarned(e, n); + console.log('admin credits', e, n, String(b.note || '').slice(0, 100)); + } + return json(res, 200, await adminMember.view(e)); + } + if (p === '/api/admin/member' && req.method === 'DELETE') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const e = String(u.searchParams.get('email') || '').trim().toLowerCase(); const acct = e && await accounts.byEmail(e); + if (!acct) return json(res, 404, { error: 'No such member.' }); + if (acct.memberId) return json(res, 400, { error: 'Member #' + acct.memberId + ' is registered on-chain and cannot be deleted.' }); + const pos = await accounts.positions(e); if (pos.some(x => x.memberId)) return json(res, 400, { error: 'This account owns a registered position and cannot be deleted.' }); + const r = await accounts.removeAccount(e); if (r.error) return json(res, 400, r); + if (db.enabled()) await db.q('DELETE FROM sessions WHERE email=?', [e]).catch(() => {}); + console.log('admin removed account', e); + return json(res, 200, { ok: true }); + } + // -- release notes + roadmap (public read; admin write) (Marty, 2026-09-14) + if (p === '/api/releases' && req.method === 'GET') return json(res, 200, releases.publicView()); + // -- promo toolkit: tiers by badge + the AI Copy Engine (Surge+), credits beyond the free allowance + if (p === '/api/my/toolkit' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + return json(res, 200, await toolkit.status(s.email)); + } + if (p === '/api/my/toolkit/generate' && 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 r = await toolkit.generate(s.email, String(b.kind || ''), String(b.brief || ''), String(b.angle || 'plain')); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/my/toolkit/template' && req.method === 'POST') { // Spark: one-tap campaign aimed at the member's link + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const b = await readBody(req); const memberId = await auth.refreshMemberId(s); + const r = await toolkit.template(s.email, memberId, (await myMemberIds(s)).ids, String(b.kind || ''), Number(b.budget) || 0); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/my/toolkit/split' && req.method === 'GET') { // Circuit: per-angle views/joins/buyers for the member's link + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + return json(res, 200, await toolkit.split(s.email)); + } + if (p === '/api/my/toolkit/videos' && req.method === 'GET') { // Circuit: Video Maker list + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + return json(res, 200, await toolkit.videos(s.email)); + } + if (p === '/api/my/toolkit/video' && 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 r = await toolkit.makeVideo(s.email, String(b.slug || '')); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/my/toolkit/team' && req.method === 'GET') { // Nexus: Leader Ops + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + return json(res, 200, await toolkit.team(s.email)); + } + if (p === '/api/my/toolkit/nudge' && 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 memberId = await auth.refreshMemberId(s); + const r = await toolkit.nudge(s.email, memberId, String(b.email || ''), String(b.text || '')); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/my/toolkit/grant' && 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 r = await toolkit.grant(s.email, String(b.email || ''), Number(b.credits) || 0); + if (r.ok) pushFeed({ type: 'Grant', from: r.fromName, to: r.toName, credits: r.credits, ts: Date.now() }); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/my/toolkit/promo' && 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 r = await toolkit.partnerCode(s.email, String(b.code || ''), Number(b.credits) || 0); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/admin/toolkit' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, await toolkit.adminUsage()); + } + if (p === '/api/leaderboard' && req.method === 'GET') { // public standings; signed-in members also get their own row + const s = await auth.fromRequest(req); + const period = ['week', 'month', 'all', 'lastweek', 'lastmonth'].includes(u.searchParams.get('period')) ? u.searchParams.get('period') : 'week'; + return json(res, 200, await leaderboard.view(period, s && s.email ? s.email : null)); + } + if (p === '/api/admin/updates' && req.method === 'GET') { // member update emails: notes, audiences, log + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const since = updates.lastSentAt(); + return json(res, 200, Object.assign({ notes: releases.notes().map(n => ({ id: n.id, title: n.title, date: n.date, fresh: !since || (n.created || 0) > since || (n.date && new Date(n.date + 'T12:00:00Z').getTime() > since) })), audiences: updates.AUDIENCES, counts: await updates.counts(), mailer: mailer.hasKey(), lastSentAt: since, draft: updates.draft() }, updates.status())); + } + if (p === '/api/admin/updates/draft' && req.method === 'POST') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, { ok: true, draft: updates.saveDraft(await readBody(req)) }); + } + if (p === '/api/admin/updates/preview' && req.method === 'POST') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + return json(res, 200, updates.compose({ subject: b.subject, intro: b.intro, closing: b.closing, noteIds: (b.noteIds || []).map(String) }, { email: ADMIN_EMAIL, username: 'you' })); + } + if (p === '/api/admin/updates/send' && req.method === 'POST') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + const r = await updates.send({ subject: b.subject, intro: b.intro, closing: b.closing, noteIds: (b.noteIds || []).map(String), audience: b.audience, to: Array.isArray(b.to) ? b.to.slice(0, 5000) : null }, { test: !!b.test }); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/admin/releases' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, { notes: releases.notes(), roadmap: releases.roadmap(), tags: releases.TAGS, statuses: releases.STATUSES }); + } + if (p === '/api/admin/releases' && req.method === 'POST') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + const r = b.kind === 'roadmap' ? releases.saveRoadmap(b) : releases.saveNote(b); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/admin/releases' && req.method === 'DELETE') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, releases.remove(u.searchParams.get('kind'), String(u.searchParams.get('id') || ''))); + } + // -- admin: blog (list all incl. drafts, save/create, delete) (Marty, 2026-09-12) + if (p === '/api/admin/blog' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const slug = u.searchParams.get('slug'); + if (slug) { const post = await blog.get(slug); return post ? json(res, 200, { post }) : json(res, 404, { error: 'No such post.' }); } + return json(res, 200, { syndication: syndicate.enabled(), posts: (await blog.listAll()).map(x => ({ slug: x.slug, title: x.title, status: x.status, tags: x.tags, publishedAt: x.publishedAt, updated: x.updated, views: x.views, excerpt: x.excerpt, cover: x.cover, syndicated: syndicate.statusOf(x.slug) })) }); + } + if (p === '/api/admin/blog' && req.method === 'POST') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + const before = b.existingSlug ? await blog.get(b.existingSlug) : null; + const r = await blog.save(b, b.existingSlug || null); + if (r.ok && r.post.status === 'published' && (!before || before.status !== 'published') && !b.noSyndicate) { + // first time this article goes live: push it to X + Instagram (never repeated for the same slug) + syndicate.publish(r.post).catch(e => console.error('syndication', e.message)); + r.syndicating = syndicate.enabled(); + } + r.syndicated = syndicate.statusOf(r.post.slug); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/admin/blog/syndicate' && req.method === 'POST') { // manual: post (or retry) a published article + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + const post = await blog.get(String(b.slug || '')); + if (!post) return json(res, 404, { error: 'No such post.' }); + if (post.status !== 'published') return json(res, 400, { error: 'Publish the article first.' }); + if (!syndicate.enabled()) return json(res, 400, { error: 'No Blotato key on the server.' }); + const st = await syndicate.publish(post, { force: !!b.force }); + return json(res, 200, { ok: true, syndicated: st }); + } + if (p === '/api/admin/blog' && req.method === 'DELETE') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const slug = u.searchParams.get('slug'); if (!slug) return json(res, 400, { error: 'slug' }); + return json(res, 200, await blog.remove(slug)); + } + // -- admin: promo codes (create/update, switch on/off, redemptions) + if (p === '/api/admin/promos' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, await promos.adminView()); + } + if (p === '/api/admin/promos' && req.method === 'POST') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + const r = await promos.create(b); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/admin/promos' && req.method === 'PATCH') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + const r = await promos.setActive(b.code, !!b.active); + return json(res, r.error ? 400 : 200, r); + } + // -- username suggestion for the required first step (Marty, 2026-09-12): email prefix, cleaned, unique + if (p === '/api/my/username-suggest' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + let base = String(s.email).split('@')[0].toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 18); + if (!/[a-z]/.test(base)) base = 'member' + base; + if (base.length < 3) base = (base + 'xyz').slice(0, 3); + let pick = base; + for (let i = 2; i < 100 && await accounts.byUsername(pick); i++) pick = base.slice(0, 20 - String(i).length) + i; + return json(res, 200, { suggest: pick }); + } + if (p === '/api/my/tank' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + return json(res, 200, await tank.view(s.email)); + } + // -- achievement badge -> Telegram (payments topic + the main group), once per badge per member. + // The browser composes the personalised image (canvas, same as Share) and posts it here. + if (p === '/api/my/badge-post' && req.method === 'POST') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const key = String(u.searchParams.get('key') || ''); + if (!BADGE_META[key]) return json(res, 400, { error: 'Unknown badge.' }); + if (!/^image\/jpeg/.test(String(req.headers['content-type'] || ''))) return json(res, 400, { error: 'Send the badge as a JPEG.' }); + let jpeg; try { jpeg = await readRaw(req, 1.5 * 1024 * 1024); } catch (e) { return json(res, 413, { error: 'Image too large.' }); } + if (!jpeg || jpeg.length < 2000 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return json(res, 400, { error: 'That is not a JPEG.' }); + const held = await ads.milestonesOf(s.email); + if (!held.includes(key)) return json(res, 400, { error: 'You have not unlocked that badge yet.' }); + const log = badgeLog(); const mine = log[s.email] || {}; + if (mine[key]) return json(res, 200, { ok: true, already: true }); + // manual re-posts (the button) are Marty's only; members' badges go out automatically on unlock + if (u.searchParams.get('manual') === '1' && s.email !== ADMIN_EMAIL) return json(res, 403, { error: 'Badges post automatically when they unlock.' }); + const a = await accounts.byEmail(s.email); + const who = a && a.username ? '@' + a.username : (a && a.memberId ? 'member #' + a.memberId : 'a member'); + const link = a && a.username ? 'linkspin-test.saasy.top/join/' + a.username : 'linkspin-test.saasy.top'; + const [label, sub] = BADGE_META[key]; + const caption = '\u{1F3C6} LinkSpin \u00b7 ' + who.replace(/[<>&]/g, '') + ' unlocked ' + label + ': ' + sub + '\n' + link; + const sc = siteConfig(); let sent = 0; + if (sc.telegramBotToken && sc.telegramEchoChatId) { + if (await telegramSendPhoto(sc.telegramEchoChatId, jpeg, caption, sc.telegramEchoTopicId)) sent++; // payments topic + if (String(sc.telegramBadgeGeneral || '1') !== '0' && await telegramSendPhoto(sc.telegramEchoChatId, jpeg, caption, null)) sent++; // the main group (General) + } + mine[key] = { ts: Date.now(), sent }; log[s.email] = mine; try { fs.writeFileSync(BADGE_LOG(), JSON.stringify(log)); } catch (e) {} + pushFeed({ type: 'Badge', member: who, label, ts: Date.now() }); + console.log('badge posted', s.email, key, 'sent', sent); + return json(res, 200, { ok: true, sent }); + } + // -- member's own share: store the composed badge so a public page can carry it as the preview image + if (p === '/api/my/badge-image' && req.method === 'POST') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const key = String(u.searchParams.get('key') || ''); + if (!BADGE_META[key]) return json(res, 400, { error: 'Unknown badge.' }); + let jpeg; try { jpeg = await readRaw(req, 1.5 * 1024 * 1024); } catch (e) { return json(res, 413, { error: 'Image too large.' }); } + if (!jpeg || jpeg.length < 2000 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return json(res, 400, { error: 'That is not a JPEG.' }); + if (!(await ads.milestonesOf(s.email)).includes(key)) return json(res, 400, { error: 'You have not unlocked that badge yet.' }); + const a = await accounts.byEmail(s.email); + if (!a || !a.username) return json(res, 400, { error: 'Pick a username first; the share page carries it.' }); + fs.writeFileSync(path.join(UPLOADS_DIR, 'badge-' + a.username + '-' + key + '.jpg'), jpeg); + return json(res, 200, { ok: true, page: 'https://linkspin-test.saasy.top/b/' + a.username + '/' + key, image: 'https://linkspin-test.saasy.top/badge-img/' + a.username + '/' + key + '.jpg' }); + } + if (p === '/api/my/badge-posted' && req.method === 'GET') { // which of my badges are already on Telegram + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + return json(res, 200, { posted: Object.keys(badgeLog()[s.email] || {}), canPost: s.email === ADMIN_EMAIL }); + } + if (p === '/api/my/tank/adopt' && 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 r = await tank.adopt(s.email, b.who, b.note); + if (r.ok) { // tell the payments topic who picked whom up (Marty, 2026-09-12) + pushFeed({ type: 'Adopted', sponsor: r.adopterName, member: r.adopteeName, ts: Date.now() }); + try { + const sc = siteConfig(); + const clean = t => String(t || '').replace(/[<>&]/g, ''); + if (sc.telegramBotToken && sc.telegramEchoChatId) + telegramSend(sc.telegramEchoChatId, '\u{1F91D} LinkSpin \u00b7 ' + clean(r.adopterName) + ' picked up ' + clean(r.adopteeName) + ' from the holding tank and is now their sponsor', sc.telegramEchoTopicId).catch(() => {}); + } catch (e) {} + } + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/my/tank/release' && 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 r = await tank.release(s.email, b.email); + if (r.ok) { // the feed shows releases too, so a pickup-and-drop is visible for what it is (Marty, 2026-09-13) + pushFeed({ type: 'Released', sponsor: r.ownerName, member: r.memberName, ts: Date.now() }); + try { + const sc = siteConfig(); + const clean = t => String(t || '').replace(/[<>&]/g, ''); + if (sc.telegramBotToken && sc.telegramEchoChatId) + telegramSend(sc.telegramEchoChatId, '\u{1FAA3} LinkSpin \u00b7 ' + clean(r.ownerName) + ' returned ' + clean(r.memberName) + ' to the holding tank', sc.telegramEchoTopicId); + } catch (e) {} + } + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/my/tank/contacted' && req.method === 'POST') { // sponsor reached the lead off-site: reset the rescue clock + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const b = await readBody(req); + const r = await tank.markContacted(s.email, b.email); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/my/gift' && req.method === 'POST') { // PIF: log a wallet-to-wallet POL gift and tell the recipient + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const b = await readBody(req); + const r = await tank.recordGift(s.email, b.email, b.tx, b.pol); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/admin/tank' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, await tank.adminView()); + } + // -- admin Traffic tab: page views + join-page views + signups + $20 buyers, by referring + // domain / first-touch source, by landing page, by angle and by day (Marty, 2026-09-12) + if (p === '/api/admin/traffic' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const days = Math.min(365, Math.max(1, Number(u.searchParams.get('days')) || 30)); + const since = Date.now() - days * 86400000, sinceDay = new Date(since).toISOString().slice(0, 10); + const hosts = {}, paths = {}, daily = {}, angles = {}; + const H = k => (hosts[k] = hosts[k] || { source: k, hits: 0, joinViews: 0, signups: 0, registered: 0, buyers: 0 }); + const Dy = k => (daily[k] = daily[k] || { day: k, hits: 0, signups: 0 }); + for (const r of await traffic.rows(sinceDay)) { H(r.host).hits += r.n; paths[r.path] = (paths[r.path] || 0) + r.n; Dy(r.day).hits += r.n; } + for (const v of await coach.viewsSince(since)) { H(v.ref || 'direct').joinViews += 1; const a = angles[v.angle || 'plain'] = angles[v.angle || 'plain'] || { angle: v.angle || 'plain', views: 0, signups: 0 }; a.views += 1; } + const buyerIds = new Set(); for (const ev of chain.recentEvents(1e9)) if (ev.type === 'BuyerCounted' && ev.newBuyerId) buyerIds.add(ev.newBuyerId); + for (const a of await accounts.listAll(5000)) { + if (!a.created || a.created < since) continue; + const h = H(a.joinedRef || 'direct'); h.signups += 1; if (a.memberId) h.registered += 1; if (a.memberId && buyerIds.has(a.memberId)) h.buyers += 1; + Dy(new Date(a.created).toISOString().slice(0, 10)).signups += 1; + const k = a.joinedVia || 'plain'; angles[k] = angles[k] || { angle: k, views: 0, signups: 0 }; angles[k].signups += 1; + } + const sources = Object.values(hosts).sort((x, y) => (y.hits + y.joinViews + y.signups * 10) - (x.hits + x.joinViews + x.signups * 10)); + return json(res, 200, { days, sources, paths: Object.entries(paths).map(([path, hits]) => ({ path, hits })).sort((x, y) => y.hits - x.hits), + angles: Object.values(angles).sort((x, y) => y.views - x.views), daily: Object.values(daily).sort((x, y) => x.day < y.day ? -1 : 1), + totals: { hits: sources.reduce((n, s) => n + s.hits, 0), joinViews: sources.reduce((n, s) => n + s.joinViews, 0), signups: sources.reduce((n, s) => n + s.signups, 0), buyers: sources.reduce((n, s) => n + s.buyers, 0) } }); + } + if (p === '/api/my/coach' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const cv = await coach.coachView(s.email); + for (const d of cv.directs) if (d.free) { try { d.rescue = await tank.rescueInfo(s.email, await accounts.byEmail(d.email)); } catch (e) {} } // dormant-lead clock + return json(res, 200, cv); + } + // -- link stats: views, joins and buyers per angle link + if (p === '/api/my/linkstats' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + return json(res, 200, await coach.linkStats(s.email)); + } + // -- prospects: the member's own follow-up list + // -- Pipeline: the follow-up board (stages from the ledger and the site; notes, follow-ups, tags from the sponsor) + if (p === '/api/my/pipeline' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const sc = siteConfig(); + if (!pipeline.visible(sc.pipelineMode, s.email, ADMIN_EMAIL)) return json(res, 200, { live: false, eta: sc.pipelineEta || '' }); + const b = await pipeline.board(s.email); + return json(res, 200, Object.assign({ live: true }, b), { 'Cache-Control': 'no-store' }); + } + if (p === '/api/my/pipeline/note' && req.method === 'POST') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + if (!pipeline.visible(siteConfig().pipelineMode, s.email, ADMIN_EMAIL)) return json(res, 403, { error: 'The Pipeline is not open yet.' }); + const r = await pipeline.save(s.email, await readBody(req)); + return json(res, r.error ? 400 : 200, r); + } + // -- the rotator (LinkSpin core tool) + if (p === '/api/my/rotations' && req.method === 'GET') { + const s = await auth.fromRequest(req); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const sc = siteConfig(); const hosts = String(sc.linkDomains || '').split(',').map(x => x.trim()).filter(Boolean); + return json(res, 200, { rotations: await rotator.list(s.email), hosts: hosts.length ? hosts : [SITE_HOST] }, { 'Cache-Control': 'no-store' }); + } + if (p === '/api/my/rotations' && req.method === 'POST') { + const s = await auth.fromRequest(req); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const r = await rotator.create(s.email, await readBody(req)); return json(res, r.error ? 400 : 200, r); + } + m = /^\/api\/my\/rotations\/(\d+)$/.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.' }); + const b = await readBody(req); const r = b.remove ? await rotator.remove(s.email, m[1]) : await rotator.update(s.email, m[1], b); return json(res, r.error ? 400 : 200, r); + } + m = /^\/api\/my\/rotations\/(\d+)\/dest$/.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.' }); + const r = await rotator.addDest(s.email, m[1], await readBody(req)); return json(res, r.error ? 400 : 200, r); + } + m = /^\/api\/my\/rotations\/(\d+)\/dest\/(\d+)$/.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.' }); + const r = await rotator.updateDest(s.email, m[1], m[2], await readBody(req)); return json(res, r.error ? 400 : 200, r); + } + m = /^\/api\/my\/rotations\/(\d+)\/stats$/.exec(p); + if (m && req.method === 'GET') { + const s = await auth.fromRequest(req); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const r = await rotator.stats(s.email, m[1]); return r ? json(res, 200, r, { 'Cache-Control': 'no-store' }) : json(res, 404, { error: 'No such rotation.' }); + } + if (p === '/api/my/carry' && req.method === 'GET') { + const s = await auth.fromRequest(req); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const st = carry.status(); const me = s.email.toLowerCase(); + return json(res, 200, { engine: st.engine, claimDays: st.claimDays, claims: st.claims.filter(c => c.member === me || c.sponsor === me), registry: await registry.get(me) }); + } + if (p === '/api/admin/carry' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, Object.assign(carry.status(), { registryCount: await registry.count(), rotator: rotator.totals() })); + } + if (p === '/api/admin/carry/seed' && req.method === 'POST') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); return json(res, 200, await carry.seedAll(Number(b.limit) || 0)); + } + if (p === '/api/admin/registry/import' && req.method === 'POST') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); const n = await registry.importRows(b.rows || [], b.property || 'instantadpay'); + // every registry row with a sponsor becomes a site account too, so lines exist before anyone signs in + let shells = 0; + if (b.shells) for (const r of await registry.all()) { const a = await accounts.byEmail(r.email); if (!a) { const x = await accounts.ensure(r.email, '', 'carried', 'network'); if (x.created) shells++; if (r.username) { try { await accounts.setUsername(r.email, r.username); } catch (e) {} } if (r.wallet) { try { await accounts.linkWallet(r.email, r.wallet); } catch (e) {} } } } + if (b.shells) for (const r of await registry.all()) { if (!r.sponsorEmail) continue; const a = await accounts.byEmail(r.email); const sp = await accounts.byEmail(r.sponsorEmail); if (a && sp && !a.sponsorRef) await accounts.setSponsorRef(r.email, sp.username || sp.code); } + return json(res, 200, { ok: true, imported: n, shells, registry: await registry.count() }); + } + if (p === '/api/my/prospects' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + return json(res, 200, { prospects: await coach.prospects(s.email), statuses: coach.STATUSES }); + } + if (p === '/api/my/prospects' && req.method === 'POST') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const r = await coach.saveProspect(s.email, await readBody(req)); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/my/prospects/remove' && 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 r = await coach.removeProspect(s.email, b.id); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/my/profile' && 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 before = await accounts.byEmail(s.email); + // a username is permanent once set: the invite link, the public wall and every + // banner already printed carry it (Marty, 2026-09-10) + if (before && before.username && String(b.username || '').trim().toLowerCase() !== String(before.username).toLowerCase()) + return json(res, 400, { error: 'Your username is locked. Your invite link, your public page and any banners you shared all carry @' + before.username + '. Contact support if it truly has to change.' }); + const r = await accounts.setUsername(s.email, b.username); + // First time a username is set (onboarding): now there's a real name to + // show, so notify the sponsor here rather than at signup (where it'd just + // say "a new member"). Fires once — only on the empty→set transition. + if (!r.error && before && !before.username && b.username) { + const acct = await accounts.byEmail(s.email); + notifyNewReferral(acct && acct.sponsorRef, acct).catch(() => {}); + // everyone's dashboard: "@name just joined" (and whether they are waiting in the tank) + if (acct && acct.username) pushFeed({ type: 'Joined', name: '@' + acct.username, tank: !acct.sponsorRef && !acct.memberId, ts: Date.now() }); + } + return json(res, r.error ? 400 : 200, r); + } + // -- earn credits by viewing ads (attention-gated daily claim) + if (p === '/api/my/earn' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + return json(res, 200, await ads.viewStatus(s.email)); + } + // fraud-guarded view flow: the server issues a single-use token when it + // serves the ad, and only counts the view if the dwell elapsed on the + // SERVER clock. Client-side focus tracking pauses the countdown; this is + // the floor a script cannot cheat past. + if (p === '/api/my/earnview' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const status = await ads.viewStatus(s.email); + if (status.views >= status.target || status.claimed) return json(res, 200, { ad: null, status }); + const type = String(u.searchParams.get('type') || 'banner'); + // members never see (or earn from) their own campaigns in the viewer + const ad = await ads.serve(type === 'text' ? 'text' : 'banner', Object.assign({ excludeEmail: s.email }, viewerGeo(req))); + if (!ad) return json(res, 200, { ad: null, status }); + const token = crypto.randomBytes(16).toString('hex'); + // the viewer tab frames the advertiser's REAL url (no click counted for a paid view) + earnTokens.set(s.email, { token, ts: Date.now(), adId: ad.id, + targetUrl: await ads.targetOf(ad.id), adName: ad.title || ad.name || null }); + return json(res, 200, { ad, token, viewUrl: '/view/' + token, status }); + } + // the viewer tab asks where to point the frame (does not consume the token) + if (p === '/api/my/viewinfo' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const t = earnTokens.get(s.email); + if (!t || t.token !== String(u.searchParams.get('token') || '')) + return json(res, 400, { error: 'That view is no longer open. Head back to the dashboard and load the next ad.' }); + return json(res, 200, { targetUrl: t.targetUrl, adName: t.adName || null, + dwell: ads.rates().viewDwellSeconds || 10 }); + } + // human check: handed out only once the dwell has elapsed on the SERVER clock + if (p === '/api/my/viewchallenge' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const t = earnTokens.get(s.email); + if (!t || t.token !== String(u.searchParams.get('token') || '')) + return json(res, 400, { error: 'That view is no longer open.' }); + const dwellMs = (ads.rates().viewDwellSeconds || 10) * 1000; + const age = Date.now() - t.ts; + if (age < dwellMs - 400) return json(res, 200, { early: true, wait: Math.ceil((dwellMs - age) / 1000) }); + if (age > 5 * 60 * 1000) { earnTokens.delete(s.email); return json(res, 400, { error: 'That ad went stale. Load a fresh one.' }); } + const pick = CAPTCHA.slice().sort(() => Math.random() - 0.5).slice(0, 5); + const answer = Math.floor(Math.random() * pick.length); + t.challenge = { answer }; + return json(res, 200, { prompt: pick[answer][1], options: pick.map(x => x[0]) }); + } + if (p === '/api/my/adview' && 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 t = earnTokens.get(s.email); + const dwellMs = (ads.rates().viewDwellSeconds || 10) * 1000; + if (!t || t.token !== String(b.token || '')) return json(res, 400, { error: 'That view did not check out. Load the next ad and let it finish.' }); + const age = Date.now() - t.ts; + if (age < dwellMs - 400) return json(res, 400, { error: 'Watch the full ad first.' }); + if (age > 5 * 60 * 1000) { earnTokens.delete(s.email); return json(res, 400, { error: 'That ad went stale. Load a fresh one.' }); } + // the human check must be solved on the same token + if (!t.challenge) return json(res, 400, { error: 'Finish the quick check first.', retry: true }); + if (Number(b.answer) !== t.challenge.answer) { + t.attempts = (t.attempts || 0) + 1; + t.challenge = null; // force a fresh challenge for the next try + if (t.attempts >= 3) { earnTokens.delete(s.email); return json(res, 400, { error: 'Three misses — that view is void. Head back and load the next ad.' }); } + return json(res, 400, { error: 'Wrong pick.', retry: true }); + } + earnTokens.delete(s.email); // single use + return json(res, 200, await ads.recordView(s.email)); + } + if (p === '/api/my/claim' && req.method === 'POST') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const r = await ads.claimDaily(s.email); + 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); + // what each person has paid THIS member so far: sum of TierPaid events where + // this member is the recipient and that person is the buyer (all indexed events) + // ...counting payouts to every position this account owns (main id + linked positions) + const myId = await auth.refreshMemberId(s); + const own = (await accounts.positions(s.email)).filter(p => p.memberId); + const myIds = new Set([myId, ...own.map(p => p.memberId)].filter(Boolean)); + const earnedBy = {}; + const qualified = new Set(); // members the contract counted as this account's qualifying buyers ($20+) + if (myIds.size) { + for (const ev of chain.recentEvents(1e9)) { + if (ev.type === 'TierPaid' && myIds.has(ev.recipientId) && ev.buyerId) + earnedBy[ev.buyerId] = (BigInt(earnedBy[ev.buyerId] || '0') + BigInt(ev.amountWei)).toString(); + if (ev.type === 'BuyerCounted' && myIds.has(ev.sponsorId) && ev.newBuyerId) qualified.add(ev.newBuyerId); + } + } + // who sponsored each person (Marty, 2026-09-14): resolve sponsorRef tokens (username, share code or member id) against the line itself + const meAcct = await accounts.byEmail(s.email); const byTok = {}; + const label = a => a.username ? '@' + a.username : (a.memberId ? 'member #' + a.memberId : 'member'); + for (const a of [meAcct, ...levels.flatMap(L => L.members)].filter(Boolean)) { const n = label(a); if (a.username) byTok[String(a.username).toLowerCase()] = n; if (a.code) byTok[String(a.code).toLowerCase()] = n; if (a.memberId) byTok[String(a.memberId)] = n; } + const sponsorOf = m => { const t = String(m.sponsorRef || '').toLowerCase(); if (!t) return null; if (meAcct && (t === String(meAcct.username || '').toLowerCase() || t === String(meAcct.code || '').toLowerCase() || t === String(meAcct.memberId || ''))) return 'you'; return byTok[t] || ('@' + t); }; + // a member's linked positions are theirs too: a qualifying buy from one of them lights their chip + const posIds = {}; for (const m of levels.flatMap(L => L.members)) { try { posIds[m.email] = (await accounts.positions(m.email)).map(p => p.memberId).filter(Boolean); } catch (e) { posIds[m.email] = []; } } + const idsOf = m => [m.memberId, ...(posIds[m.email] || [])].filter(Boolean); + // Clinton's ask (2026-09-14): on levels 2 and 3 show who has made their $20+ buy (counted for their own + // sponsor) and how many qualifying buyers of their own they have, so a leader can see who is one short + const onchain = {}; + for (const m of levels.flatMap(L => L.members)) { let bought = false, buyers = 0; for (const id of idsOf(m)) { const mm = await cachedMember(id); if (mm) { if (mm.countedAsBuyer) bought = true; buyers += mm.buyerCount || 0; } } onchain[m.email] = { bought, buyers }; } + const out = levels.map(L => ({ level: L.level, members: L.members.map(m => ({ + bought: onchain[m.email].bought, buyers: onchain[m.email].buyers, + memberId: m.memberId || 0, + sponsor: sponsorOf(m), + name: m.username ? '@' + m.username : m.memberId ? 'member #' + m.memberId : 'member', + ref: m.code || null, // opens the activity drop-down (Clinton's ask, 2026-09-14): any of the three levels + email: L.level === 1 ? m.email : null, // directs only + joined: m.created, + qualified: idsOf(m).some(id => qualified.has(id)), + earnedWei: idsOf(m).reduce((n, id) => n + BigInt(earnedBy[id] || '0'), 0n).toString() })) })); + // the member's own linked positions sit on level 1 too, labelled as theirs + if (own.length) { + if (!out.find(L => L.level === 1)) out.unshift({ level: 1, members: [] }); + const L1 = out.find(L => L.level === 1); + own.forEach((p, i) => L1.members.push({ memberId: p.memberId, name: 'You · position ' + (i + 2), own: true, + email: null, joined: p.created, earnedWei: earnedBy[p.memberId] || '0' })); + } + return json(res, 200, { levels: out, counts: out.map(L => L.members.length) }); + } + // -- who is working: one downline member's activity, any of the three levels (Clinton, 2026-09-14) + if (p === '/api/my/line/activity' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const ref = String(u.searchParams.get('ref') || '').trim().toLowerCase().slice(0, 40); + const a = ref ? await accounts.byCode(ref) : null; + if (!a || !(await accounts.isDownlineOf(s.email, a.email, 3))) return json(res, 404, { error: 'Not in your line.' }); + const now = Date.now(); + const out = { name: a.username ? '@' + a.username : (a.memberId ? 'member #' + a.memberId : 'member'), joined: a.created, lastSeen: a.lastSeen || 0, quietDays: Math.floor((now - Math.max(a.created || 0, a.lastSeen || 0)) / 86400000), wallet: !!a.address, memberId: a.memberId || 0 }; + try { const c = await coach.describe(a, now); out.rung = c.label; out.next = c.next; out.stalled = c.stalled; out.buyerCount = c.buyerCount || 0; } catch (e) {} + try { const v = await ads.viewStatus(a.email); out.viewsToday = v.views; out.target = v.target; out.claimedToday = v.claimed; out.streakDay = v.claimed ? v.streakDay : Math.max(0, v.streakDay - 1); } catch (e) {} + try { const cs = await ads.listCampaigns(a.email); out.campaigns = cs.length; out.campaignsActive = cs.filter(c => c.status === 'active').length; out.imps = cs.reduce((n, c) => n + (c.imps || 0) + (c.impsNas || 0), 0); } catch (e) {} + try { const ls = await coach.linkStats(a.email); out.linkViews30 = (ls.angles || []).reduce((n, r) => n + (r.views30 || 0), 0); out.linkViews = ls.totalViews || 0; out.joins = ls.totalJoins || 0; } catch (e) {} + try { out.directs = ((await accounts.downline(a.email, 1))[0] || { members: [] }).members.length; } catch (e) { out.directs = 0; } + try { out.badges = await ads.milestonesOf(a.email); } catch (e) { out.badges = []; } + try { out.positions = (await accounts.positions(a.email)).length; } catch (e) { out.positions = 0; } + return json(res, 200, out); + } + // -- 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 LinkSpin upline. Read it in your dashboard: https://linkspin-test.saasy.top/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])); + } + + // ── 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) return json(res, 200, { ok: true, chatUnread: 0 }); + accounts.touchSeen(s.email).catch(() => {}); + return json(res, 200, { ok: true, chatUnread: await messages.chatUnread(s.email) }); + } + // training center content (admin-curated via data/training.json) + if (p === '/api/training' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s) return json(res, 401, { error: 'Sign in first.' }); + let items = []; + try { const j = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'training.json'), 'utf8')); if (Array.isArray(j)) items = j; } catch (e) {} + // a card with feature:'pipeline' waits for the switch, so the video can be ready before the tab opens (Marty, 2026-09-15) + const scT = siteConfig(); items = items.filter(it => !it.feature || (it.feature === 'pipeline' && pipeline.visible(scT.pipelineMode, s.email, ADMIN_EMAIL))); + if (!items.length) items = [{ title: 'Getting started with LinkSpin', + desc: 'How the platform works, how every payout splits on-chain to real wallets, and how to build your line.', + docUrl: 'https://linkspin-test.saasy.top/' }]; + return json(res, 200, { items }); + } + // daily login bonus (once/day, gentle streak) — granted after the login flow + if (p === '/api/my/login-bonus' && 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 ads.grantLoginBonus(s.email)); + } + // -- rehearsal test-POL faucet: top a connected wallet up to 10 test-POL + // (anvil_setBalance, no key needed). Rehearsal-only, rate-limited. + if (p === '/api/my/faucet' && req.method === 'POST') { + const s = await auth.fromRequest(req); + if (!s) return json(res, 401, { error: 'Sign in first.' }); + if (!siteConfig().rehearsal) return json(res, 400, { error: 'The faucet is only open during the rehearsal.' }); + const b = await readBody(req); + const addr = String(b.address || '').trim().toLowerCase(); + if (!/^0x[0-9a-f]{40}$/.test(addr)) return json(res, 400, { error: 'Connect your wallet first.' }); + // Amoy is a public testnet: testers fund their own connected wallet from + // the public Amoy faucet (no server-minted balance). We just echo the + // address + faucet link; the client copies the address and opens it. + let balHex = '0x0'; try { balHex = await chain.rpc('eth_getBalance', [addr, 'latest']); } catch (e) {} + return json(res, 200, { ok: true, faucetUrl: 'https://faucet.polygon.technology/', address: addr, balanceWei: BigInt(balHex || '0x0').toString() }); + } + // -- report an ad (auto-approved ads need a safety valve): store + notify admin + if (p === '/api/report-ad' && req.method === 'POST') { + const b = await readBody(req); + if (!Number(b.campaignId)) return json(res, 400, { error: 'Which ad?' }); + const s = await auth.fromRequest(req); + const who = (s && s.email) || ''; + const rec = await reports.add(b.campaignId, who, b.reason, b.note); + try { + const adminEmail = process.env.ADMIN_EMAIL || ''; // private env only — siteConfig is exposed via /api/config + if (adminEmail && mailer.hasKey()) { + mailer.send(adminEmail, 'Ad reported on LinkSpin (campaign #' + rec.campaignId + ')', + 'A member flagged an ad.\n\nCampaign: #' + rec.campaignId + '\nReason: ' + rec.reason + + '\nReported by: ' + (who || 'anonymous') + '\nNote: ' + (String(b.note || '').slice(0, 500) || '(none)') + + '\n\nPause or review it from the admin.').catch(() => {}); + } + } catch (e) {} + 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 LinkSpin line'; + mailer.send(to, 'New message from ' + from, + text.slice(0, 400) + '\n\n— reply in your dashboard: https://linkspin-test.saasy.top/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(viewerGeo(req)); + try { const sv = await auth.fromRequest(req); ads.noteFeaturedViews(items, (sv && sv.email) || clientIp(req)).catch(() => {}); } catch (e) {} // count the view (per viewer per hour) + const names = await accounts.namesForMembers([...new Set(items.map(i => i.memberId).filter(Boolean))]); + for (const i of items) i.by = (i.memberId && names[i.memberId]) ? '@' + names[i.memberId] : (i.memberId ? 'member #' + i.memberId : null); + return json(res, 200, { items }); + } + if (p === '/api/featured/stats' && req.method === 'GET') { + return json(res, 200, await ads.featuredStats()); + } + // -- QR code (SVG) for any LinkSpin URL — used on bio/wall pages + if (p === '/api/qr' && req.method === 'GET') { + const data = String(u.searchParams.get('d') || '').slice(0, 300); + if (!QR || !data) { res.writeHead(404, baseHeaders()); return res.end(); } + try { + const svg = await QR.toString(data, { type: 'svg', margin: 1, width: 240, + color: { dark: '#0e7d5f', light: '#f2fbf8' } }); // mint-green modules, still high-contrast for reliable scanning + res.writeHead(200, baseHeaders({ 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' })); + return res.end(svg); + } catch (e) { res.writeHead(500, baseHeaders()); return res.end(); } + } + // -- profile: avatar + bio + if (p === '/api/my/profile-details' && 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 avatar = b.avatarUrl === undefined ? undefined : String(b.avatarUrl || '').trim(); + if (avatar && !/^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|https:\/\/[^\s]+)$/i.test(avatar)) + return json(res, 400, { error: 'Avatar must be an uploaded image or an https image URL.' }); + const bio = b.bio === undefined ? undefined : String(b.bio || '').trim().slice(0, 600); + // socials: {platform: url}; keep only known platforms with valid https urls + let socials; + if (b.socials !== undefined) { + const PLAT = ['facebook', 'twitter', 'youtube', 'instagram', 'tiktok', 'telegram', 'linkedin', 'website', 'video']; + const clean = {}; + for (const p of PLAT) { + const v = String((b.socials && b.socials[p]) || '').trim(); + if (v && /^https:\/\/[^\s]+$/i.test(v) && v.length <= 300) clean[p] = v; + } + // intro video on the public wall: a YouTube, Vimeo or direct .mp4/.webm link only + if (clean.video && !/^https:\/\/((www\.|m\.)?youtube\.com\/(watch\?|shorts\/|embed\/)|youtu\.be\/|(www\.)?vimeo\.com\/\d+|player\.vimeo\.com\/video\/\d+|[^\s]+\.(mp4|webm)(\?|$))/i.test(clean.video)) + return json(res, 400, { error: 'The intro video needs to be a YouTube link, a Vimeo link, or a direct .mp4 link.' }); + socials = Object.keys(clean).length ? JSON.stringify(clean) : null; + } + const r = await accounts.setProfile(s.email, avatar, bio, socials); + return json(res, r.error ? 400 : 200, r); + } + // -- wall positions 2 & 3: the member's own offers, unlocked at 2 / 5 qualifying buyers + if (p === '/api/my/wall-offers' && 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 src = Array.isArray(b.offers) ? b.offers.slice(0, 2) : []; + const out = []; + for (let i = 0; i < 2; i++) { + const o = src[i] || {}; + const targetUrl = String(o.targetUrl || '').trim(); + const bannerUrl = String(o.bannerUrl || '').trim(); + const title = String(o.title || '').trim().slice(0, 60); + if (targetUrl && !/^https:\/\/[^\s]+$/i.test(targetUrl)) return json(res, 400, { error: 'Position ' + (i + 2) + ': the link must start with https://' }); + if (bannerUrl && !/^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|https:\/\/[^\s]+)$/i.test(bannerUrl)) return json(res, 400, { error: 'Position ' + (i + 2) + ': banner must be an uploaded image or an https image URL.' }); + out.push(targetUrl ? { title: title || null, bannerUrl: bannerUrl || null, targetUrl } : null); + } + const r = await accounts.setWallOffers(s.email, out.some(Boolean) ? JSON.stringify(out) : null); + return json(res, r.error ? 400 : 200, r); + } + // -- line banner: the member's viral slot on welcome tours + their wall + if (p === '/api/my/linebanner' && 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.targetUrl || '').trim(); + if (!/^https?:\/\/[^\s]+$/i.test(target)) return json(res, 400, { error: 'Destination URL must start with http(s)://' }); + const fc = await frameCheck(target); // welcome tours frame it full screen + if (!fc.ok) return json(res, 400, { error: fc.reason }); + const banner = String(b.bannerUrl || '').trim(); + if (banner && !/^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|https:\/\/[^\s]+)$/i.test(banner)) + return json(res, 400, { error: 'Banner must be an uploaded image or an https image URL.' }); + const r = await accounts.setLineBanner(s.email, banner || null, target); + return json(res, r.error ? 400 : 200, r); + } + // -- welcome tour (gauntlet): meet the 3-level upline, then unlock welcome credits + if (p === '/api/my/gauntlet' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + if (await ads.welcomeGranted(s.email)) return json(res, 200, { pending: false }); + const slides = (await uplineSlides(s.email)).filter(a => a.lineTargetUrl) + .map((a, i) => ({ name: a.username ? '@' + a.username : a.memberId ? 'member #' + a.memberId : 'a member', + bannerUrl: a.lineBannerUrl || null, targetUrl: a.lineTargetUrl })); + if (!slides.length) return json(res, 200, { pending: false }); + const token = crypto.randomBytes(16).toString('hex'); + gauntletTokens.set(s.email, { token, ts: Date.now(), n: slides.length }); + return json(res, 200, { pending: true, slides, dwell: 10, token }); + } + if (p === '/api/my/gauntlet/complete' && 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 t = gauntletTokens.get(s.email); + if (!t || t.token !== String(b.token || '')) return json(res, 400, { error: 'That tour is no longer open. Reload and try again.' }); + if (Date.now() - t.ts < t.n * 10 * 1000 - 1500) return json(res, 400, { error: 'Give each site its ten seconds first.' }); + gauntletTokens.delete(s.email); + await ads.grantWelcome(s.email); + return json(res, 200, { ok: true, credited: ads.rates().welcomeCredits || 0 }); + } + // -- public banner wall + m = /^\/api\/wall\/([A-Za-z0-9_]{1,20})$/.exec(p); + if (m && req.method === 'GET') { + const tok = m[1].toLowerCase(); + let a = await accounts.byUsername(tok); + if (!a) a = await accounts.byCode(tok); + if (!a) return json(res, 404, { error: 'No wall under that name.' }); + const ownName = a.username ? '@' + a.username : a.memberId ? 'member #' + a.memberId : 'a member'; + let bc = 0; + if (a.memberId) { try { bc = (await chain.member(a.memberId)).buyerCount || 0; } catch (e) {} } + const unlocked = wallUnlockedFor(bc); + const offers = parseWallOffers(a); + // uplines with a live banner, in order (an upline with nothing set is skipped, not shown empty) + const ups = (await uplineSlides(a.email, 2)).filter(x => x.lineTargetUrl) + .map(x => ({ name: x.username ? '@' + x.username : x.memberId ? 'member #' + x.memberId : 'a member', + bannerUrl: x.lineBannerUrl || null, targetUrl: x.lineTargetUrl, upline: true })); + const adAds = getAdminWallAds(); let ai = 0; + const houseAd = () => { if (!adAds.length) return null; const ad = adAds[ai++ % adAds.length]; return { name: ad.name || 'LinkSpin', bannerUrl: ad.bannerUrl || null, targetUrl: ad.targetUrl || 'https://linkspin-test.saasy.top/', admin: true }; }; + const ladder = [{ name: ownName, bannerUrl: a.lineBannerUrl || null, targetUrl: a.lineTargetUrl || null, own: true }]; + for (let i = 1; i < 3; i++) { + const o = offers[i - 1]; + if (i < unlocked && o && o.targetUrl) { ladder.push({ name: (o.title || ownName), bannerUrl: o.bannerUrl || null, targetUrl: o.targetUrl, own: true }); continue; } + const u = ups.shift(); if (u) { ladder.push(u); continue; } + const h = houseAd(); if (h) ladder.push(h); + } + const finalLadder = ladder.slice(0, 3); + // the wall owner's achievement badge (their highest reached tier) + let badge = null; + if (a.memberId) { + const t = bc >= 5 ? ['nexus', 'Nexus'] : bc >= 2 ? ['circuit', 'Circuit'] : bc >= 1 ? ['surge', 'Surge'] : ['spark', 'Spark']; + badge = { img: '/badges/badge-' + t[0] + '.jpg?v=2', label: t[1] }; + } + const joinPath = '/join/' + (a.username || a.code); + let socials = null; try { socials = a.socials ? JSON.parse(a.socials) : null; } catch (e) {} + return json(res, 200, { name: a.username ? '@' + a.username : 'member #' + (a.memberId || 0), + avatarUrl: a.avatarUrl || null, bio: a.bio || null, socials, badge, + joinUrl: joinPath, qrUrl: '/api/qr?d=' + encodeURIComponent('https://linkspin-test.saasy.top' + joinPath), ladder: finalLadder, unlocked, buyerCount: bc }); + } + // -- watch-to-earn video ads: serve one, then reward a server-clock-verified watch + if (p === '/api/my/videos' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const status = await ads.videoStatus(s.email); + if (status.left <= 0) return json(res, 200, { ad: null, status }); + const orientation = String(u.searchParams.get('orientation') || ''); // 'portrait' = Shorts reel, 'landscape' = Watch videos tab + const ad = await ads.serveVideo(Object.assign({ excludeEmail: s.email, orientation }, viewerGeo(req))); // never your own video + if (!ad) { // distinguish "you have watched every live video today" from "nothing is live" (Marty, 2026-09-13) + let allWatched = false; try { allWatched = !!(await ads.serveVideo(Object.assign({ excludeEmail: s.email, orientation, ignoreSeen: true }, viewerGeo(req)))); } catch (e) {} + return json(res, 200, { ad: null, status, allWatched }); + } + const token = crypto.randomBytes(16).toString('hex'); + videoTokens.set(s.email, { token, ts: Date.now(), id: ad.id, secs: ad.watchSecs }); + return json(res, 200, { ad, token, status }); + } + if (p === '/api/my/videowatch' && 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 t = videoTokens.get(s.email); + if (!t || t.token !== String(b.token || '')) return json(res, 400, { error: 'That video is no longer open. Load the next one.' }); + const age = Date.now() - t.ts; + if (age < t.secs * 1000 - 600) return json(res, 400, { error: 'Watch the full video first.' }); + if (age > t.secs * 1000 + 10 * 60 * 1000) { videoTokens.delete(s.email); return json(res, 400, { error: 'That watch went stale. Load a fresh video.' }); } + videoTokens.delete(s.email); // single use + if (await ads.hasWatchedVideoToday(s.email, t.id)) // once-per-day-per-video: no double earning + return json(res, 200, { ok: true, credited: 0, status: await ads.videoStatus(s.email), already: true }); + const tier = await ads.chargeVideoView(t.id); // charge advertiser; null if it ran dry + if (!tier) return json(res, 200, { ok: true, credited: 0, status: await ads.videoStatus(s.email), gone: true }); + await ads.addEarned(s.email, tier.reward); + await ads.markVideoSeen(s.email, t.id); + const status = await ads.recordVideoWatch(s.email); + return json(res, 200, { ok: true, credited: tier.reward, status }); + } + // -- verified visits: view a member's site (new tab) for the dwell, pass a + // human check, and it counts as one guaranteed unique visit for the pack + if (p === '/api/my/visits' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const status = await ads.visitStatus(s.email); + if (status.count >= status.cap) return json(res, 200, { ad: null, status }); + const ad = await ads.serveVisit(s.email, viewerGeo(req)); + if (!ad) return json(res, 200, { ad: null, status }); + const token = crypto.randomBytes(16).toString('hex'); + visitTokens.set(s.email, { token, ts: Date.now(), id: ad.id }); + return json(res, 200, { ad, token, status }); + } + if (p === '/api/my/visitchallenge' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const t = visitTokens.get(s.email); + if (!t || t.token !== String(u.searchParams.get('token') || '')) return json(res, 400, { error: 'That visit is no longer open.' }); + const dwellMs = (ads.rates().visitDwellSeconds || 8) * 1000; + const age = Date.now() - t.ts; + if (age < dwellMs - 400) return json(res, 200, { early: true, wait: Math.ceil((dwellMs - age) / 1000) }); + if (age > 5 * 60 * 1000) { visitTokens.delete(s.email); return json(res, 400, { error: 'That visit went stale. Load a fresh one.' }); } + const pick = CAPTCHA.slice().sort(() => Math.random() - 0.5).slice(0, 5); + const answer = Math.floor(Math.random() * pick.length); + t.challenge = { answer }; + return json(res, 200, { prompt: pick[answer][1], options: pick.map(x => x[0]) }); + } + if (p === '/api/my/visitdone' && 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 t = visitTokens.get(s.email); + if (!t || t.token !== String(b.token || '')) return json(res, 400, { error: 'That visit did not check out. Load the next one.' }); + const dwellMs = (ads.rates().visitDwellSeconds || 8) * 1000; + if (Date.now() - t.ts < dwellMs - 400) return json(res, 400, { error: 'Give the site the full visit first.' }); + if (!t.challenge) return json(res, 400, { error: 'Finish the quick check first.', retry: true }); + if (Number(b.answer) !== t.challenge.answer) { t.challenge = null; return json(res, 400, { error: 'Wrong pick.', retry: true }); } + visitTokens.delete(s.email); + const r = await ads.completeVisit(s.email, t.id); + if (r.error) return json(res, 400, r); + return json(res, 200, Object.assign(r, { status: await ads.visitStatus(s.email) })); + } + // -- onsite solo ads: member inbox with read rewards + if (p === '/api/my/inbox' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const r = await ads.inboxList(s.email, viewerGeo(req)); + const names = await accounts.namesForMembers([...new Set(r.items.map(i => i.fromMemberId).filter(Boolean))]); + for (const i of r.items) i.fromName = (i.fromMemberId && names[i.fromMemberId]) ? '@' + names[i.fromMemberId] + : i.fromMemberId ? 'member #' + i.fromMemberId : 'a member'; + return json(res, 200, r); + } + m = /^\/api\/my\/inbox\/(\d+)$/.exec(p); + if (m && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const r = await ads.inboxOpen(s.email, m[1]); + if (!r.error) { + const names = r.fromMemberId ? await accounts.namesForMembers([r.fromMemberId]) : {}; + r.fromName = (r.fromMemberId && names[r.fromMemberId]) ? '@' + names[r.fromMemberId] + : r.fromMemberId ? 'member #' + r.fromMemberId : 'a member'; + } + return json(res, r.error ? 404 : 200, r); + } + // media upload for solo ads: raw body, size-capped, magic-byte verified + if (p === '/api/my/upload' && req.method === 'POST') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + return handleUpload(req, res, s.email); + } + m = /^\/api\/my\/inbox\/(\d+)\/visit$/.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.' }); + const r = await ads.markSoloVisit(s.email, m[1]); + return json(res, r.error ? 400 : 200, r); + } + m = /^\/api\/my\/inbox\/(\d+)\/claim$/.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.' }); + const r = await ads.claimSoloRead(s.email, m[1]); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/my/activity' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s) return json(res, 401, { error: 'Sign in first.' }); + const id = s.memberId || await auth.refreshMemberId(s); + if (!id) return json(res, 200, { memberId: 0, earnings: [], purchases: [], referrals: [] }); + const evs = chain.recentEvents(600); + return json(res, 200, { + memberId: id, + earnings: await attachNames(evs.filter(e => (e.type === 'TierPaid' && e.recipientId === id) || (e.type === 'AwardPaid' && e.toId === id))), + purchases: await attachNames(evs.filter(e => e.type === 'Purchase' && e.buyerId === id)), + referrals: await attachNames(evs.filter(e => (e.type === 'MemberActivated' && e.sponsorId === id) || (e.type === 'BuyerCounted' && e.sponsorId === id))) + }); + } + + // -- ad engine (spec §8b v1: banners, text, login ads) + if (p === '/api/ads/slot' && req.method === 'GET') { + const t = String(u.searchParams.get('type') || 'banner'); + const ad = await ads.serve(t, Object.assign({ width: Number(u.searchParams.get('w')) || 0, height: Number(u.searchParams.get('h')) || 0 }, viewerGeo(req))); + // login ads: the member clicks "Open Ad" (a real, counted click into a + // new tab) while the countdown runs on our interstitial page + if (ad && t === 'login') ad.dwell = ads.rates().loginDwellSeconds || 10; + return json(res, 200, { ad }); + } + m = /^\/api\/ads\/click\/(\d+)$/.exec(p); + if (m && req.method === 'GET') { + const target = await ads.click(m[1]); + if (!target) { res.writeHead(404, baseHeaders()); return res.end(); } + coach.recordClick(m[1], req.headers.referer); // where the click happened + res.writeHead(302, baseHeaders({ Location: target })); + return res.end(); + } + if (p === '/api/my/campaigns' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const memberId = await auth.refreshMemberId(s); + const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() }; + out.clickSources = await coach.clickSources(out.campaigns.map(c => c.id)); + out.hours = await ads.hoursFor(out.campaigns.map(c => c.id)); // on-site views per UTC hour, last 7 days + out.geo = await ads.geoFor(out.campaigns.map(c => c.id)); // on-site serves per viewer country + { const t = geo.tierLists(siteConfig()); out.tiers = { t1: [...t.t1], t2: [...t.t2] }; out.geoReady = geo.status().loaded; } + const pool = await ads.balances((await myMemberIds(s)).ids, s.email); + out.purchasedCredits = pool.total; + out.largestPosition = pool.best.avail; // a single campaign budget has to fit one position + out.positionCount = pool.per.length; + out.creditedCredits = pool.credited; // refunded/credited purchased money, free (counts inside purchasedCredits) + out.earnedCredits = pool.earned; // earned pool minus what live campaigns already hold + out.earnedReserved = pool.earnedReserved; + out.inCampaigns = pool.inCampaigns; // budget still to deliver across live campaigns + out.availableCredits = pool.available; + return json(res, 200, out); + } + if (p === '/api/my/campaigns' && req.method === 'POST') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const memberId = await auth.refreshMemberId(s); // 0 is fine: earned credits fund banner/text + const b = await readBody(req); + if (!['login', 'solo', 'video', 'featured'].includes(String(b.type || ''))) { // banner/text surf views frame the target; login/video/solo/featured open in a new tab or play in our own player + const fc = await frameCheck(b.targetUrl); + if (!fc.ok) return json(res, 400, { error: fc.reason }); + } + // charge the best-funded of the member's positions (main + Qualified Start + // wallets). A campaign burns from one member id, so the budget must fit inside it. + const ids = (await myMemberIds(s)).ids; + const pool = await ads.balances(ids, s.email); + const fundId = pool.best.memberId || memberId; + const earnedNow = String(b.type) !== 'login' ? pool.earned : 0; + const budget = Math.floor(Number(b.budget) || 0); + if (pool.per.length > 1 && budget > pool.best.avail + pool.credited + earnedNow && budget <= pool.total + earnedNow) + return json(res, 400, { error: 'Your credits are spread across ' + pool.per.length + ' positions and one campaign spends from one of them. The largest single position holds ' + pool.best.avail + ' credits: set the budget to that or less, or run two campaigns.' }); + const r = await ads.createCampaign(s.email, fundId, b, ids); + return json(res, r.error ? 400 : 200, r); + } + m = /^\/api\/my\/campaigns\/(\d+)\/topup$/.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.' }); + const memberId = await auth.refreshMemberId(s); + const b = await readBody(req); + const r = await ads.topUpCampaign(s.email, memberId, m[1], b.credits); + return json(res, r.error ? 400 : 200, r); + } + m = /^\/api\/my\/campaigns\/(\d+)\/(pause|resume)$/.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.' }); + const r = await ads.setStatus(s.email, m[1], m[2] === 'pause' ? 'paused' : 'active'); + return json(res, r.error ? 400 : 200, r); + } + + // -- admin portal: email magic-code sign-in, allowlisted to ADMIN_EMAIL + if (p === '/api/admin/auth/start' && req.method === 'POST') { + const b = await readBody(req); + const e = String(b.email || '').trim().toLowerCase(); + if (!ADMIN_EMAIL) return json(res, 503, { error: 'ADMIN_EMAIL is not set on the server.' }); + if (!e || e !== ADMIN_EMAIL) return json(res, 403, { error: 'That address is not the admin.' }); + const k = 'admin:' + e; + const prev = emailCodes.get(k); + if (prev && Date.now() < prev.nextAt) return json(res, 429, { error: 'Code already sent. Give it a minute, then try again.' }); + const code = String(Math.floor(100000 + Math.random() * 900000)); + emailCodes.set(k, { code, exp: Date.now() + 15 * 60 * 1000, tries: 0, nextAt: Date.now() + 60 * 1000 }); + if (mailer.hasKey()) { + try { await mailer.sendCode(e, code); } catch (err) { + console.error('admin sendCode failed', err.message); + return json(res, 502, { error: 'Could not send the email. Try again in a minute.' }); + } + return json(res, 200, { ok: true, sent: true }); + } + if (!IS_PROD) return json(res, 200, { ok: true, sent: false, devCode: code }); + return json(res, 503, { error: 'Email sign-in is not configured yet.' }); + } + if (p === '/api/admin/auth/verify' && req.method === 'POST') { + const b = await readBody(req); + const e = String(b.email || '').trim().toLowerCase(); + const k = 'admin:' + e; + const rec = emailCodes.get(k); + if (!rec || rec.exp < Date.now()) return json(res, 400, { error: 'Code expired. Request a fresh one.' }); + rec.tries += 1; + if (rec.tries > 6) { emailCodes.delete(k); return json(res, 400, { error: 'Too many tries. Request a fresh code.' }); } + if (String(b.code || '').trim() !== rec.code) return json(res, 400, { error: 'That code does not match.' }); + emailCodes.delete(k); + if (e !== ADMIN_EMAIL) return json(res, 403, { error: 'That address is not the admin.' }); + const token = mintAdminSession(e); + return json(res, 200, { ok: true, email: e }, { 'Set-Cookie': adminCookie(token) }); + } + if (p === '/api/admin/auth/logout' && req.method === 'POST') { + dropAdminSession(req); + return json(res, 200, { ok: true }, { 'Set-Cookie': clearAdminCookie() }); + } + if (p === '/api/admin/me' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 200, { admin: false }); + return json(res, 200, { admin: true, email: ADMIN_EMAIL }); + } + if (p === '/api/admin/overview' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const camps = await ads.adminList(); + const byStatus = {}, byType = {}; + for (const c of camps) { byStatus[c.status] = (byStatus[c.status] || 0) + 1; byType[c.type] = (byType[c.type] || 0) + 1; } + let memberCount = null; try { memberCount = await chain.memberCount(); } catch (e) {} + const cc = chain.getConfig(); + return json(res, 200, { accounts: await accounts.count(), memberCount, campaigns: camps.length, + house: camps.filter(c => c.house).length, byStatus, byType, + openReports: await reports.openCount(), pendingBurns: (await ads.pendingBurns()).length, + followups: await drip.stats(), + chain: { contract: cc.contract, chainId: cc.chainId, chainName: cc.chainName, explorer: cc.explorer }, + site: siteConfig(), rates: ads.rates() }); + } + if (p === '/api/admin/members' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const members = await accounts.listAll(500); + // resolve each sponsor token (username, share code or member #) to the sponsor's name + const byTok = {}; + for (const m of members) for (const t of [m.username, m.code, m.memberId ? String(m.memberId) : null]) if (t) byTok[String(t).toLowerCase()] = m; + for (const m of members) { + const t = String(m.sponsorRef || '').toLowerCase(); + const sp = t ? byTok[t] : null; + m.sponsorName = sp ? (sp.username ? '@' + sp.username : (sp.memberId ? 'member #' + sp.memberId : sp.email)) : null; + m.sponsorVia = sp ? (t === String(sp.username || '').toLowerCase() ? 'username' : t === String(sp.code || '').toLowerCase() ? 'code' : 'member #') : (t ? 'unresolved' : ''); + } + for (const m of members) { try { const ps = await accounts.positions(m.email); m.positions = ps.length; m.positionIds = ps.map(p => p.memberId).filter(Boolean); } catch (e) { m.positions = 0; } } + return json(res, 200, { members }); + } + if (p === '/api/admin/members' && req.method === 'PATCH') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + if (!b.email) return json(res, 400, { error: 'Which member?' }); + const r = await accounts.setSponsorRef(b.email, b.sponsorRef); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/admin/campaigns' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, { campaigns: await ads.adminList(), rates: ads.rates(), bannerSizes: ads.bannerSizes(), houseOwner: ads.HOUSE_OWNER }); + } + if (p === '/api/admin/campaigns' && req.method === 'POST') { // free house ad + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + if (!['login', 'solo', 'video', 'featured'].includes(String(b.type || ''))) { + const fc = await frameCheck(b.targetUrl); + if (!fc.ok) return json(res, 400, { error: fc.reason }); + } + const r = await ads.createHouseCampaign(b); + return json(res, r.error ? 400 : 200, r); + } + m = /^\/api\/admin\/campaigns\/(\d+)\/(pause|resume)$/.exec(p); + if (m && req.method === 'POST') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const r = await ads.adminSetStatus(m[1], m[2] === 'pause' ? 'paused' : 'active'); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/admin/audit' && req.method === 'GET') { // counters reconciled against delivery logs + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, await audit.run()); + } + if (p === '/api/admin/reports' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, { reports: await reports.list(200) }); + } + m = /^\/api\/admin\/reports\/(\d+)\/resolve$/.exec(p); + if (m && req.method === 'POST') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, await reports.resolve(m[1])); + } + if (p === '/api/admin/upload' && req.method === 'POST') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return handleUpload(req, res, 'admin'); + } + if (p === '/api/admin/rates' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, { rates: ads.rates() }); + } + if (p === '/api/admin/drip' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, { sequence: drip.sequence(), defaults: drip.DEFAULT_SEQUENCE, stats: await drip.stats(), mailReady: mailer.hasKey() }); + } + if (p === '/api/admin/drip' && req.method === 'PATCH') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + const r = b.reset ? drip.resetSequence() : drip.setSequence(b.sequence); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/admin/drip/test' && req.method === 'POST') { // send one step to the admin inbox + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + if (!ADMIN_EMAIL) return json(res, 400, { error: 'ADMIN_EMAIL is not set.' }); + if (!mailer.hasKey()) return json(res, 400, { error: 'No mail key on the server.' }); + try { const r = await drip.sendStep(ADMIN_EMAIL, Number(b.step) || 0, ADMIN_EMAIL); return json(res, r.error ? 400 : 200, r); } + catch (e) { return json(res, 502, { error: 'Send failed: ' + e.message }); } + } + // wall fallback ads: shown in wall positions a member has not earned or filled, when no upline banner exists + if (p === '/api/admin/wall-ads' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + let saved = null; try { saved = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'admin-wall-ads.json'), 'utf8')); } catch (e) {} + return json(res, 200, { ads: Array.isArray(saved) ? saved : [], defaults: getAdminWallAds(), usingDefaults: !Array.isArray(saved) || !saved.length }); + } + if (p === '/api/admin/wall-ads' && req.method === 'PATCH') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + const src = Array.isArray(b.ads) ? b.ads.slice(0, 20) : []; + const out = []; + for (const o of src) { + const name = String((o && o.name) || '').trim().slice(0, 60); + const targetUrl = String((o && o.targetUrl) || '').trim(); + const bannerUrl = String((o && o.bannerUrl) || '').trim(); + if (!targetUrl) continue; + if (!/^https:\/\/[^\s]+$/i.test(targetUrl)) return json(res, 400, { error: 'Every wall ad needs an https:// link (' + (name || targetUrl) + ').' }); + if (bannerUrl && !/^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|https:\/\/[^\s]+)$/i.test(bannerUrl)) return json(res, 400, { error: 'Banner must be an uploaded image or an https image URL (' + (name || targetUrl) + ').' }); + out.push({ name: name || 'LinkSpin', targetUrl, bannerUrl: bannerUrl || null }); + } + const file = path.join(DATA_DIR, 'admin-wall-ads.json'); + if (out.length) fs.writeFileSync(file, JSON.stringify(out, null, 2)); else { try { fs.unlinkSync(file); } catch (e) {} } + return json(res, 200, { ok: true, ads: out, usingDefaults: !out.length }); + } + // -- growth snapshot: preview the post, or send it now + if (p === '/api/admin/snapshot' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, { text: await snapshot.preview() }); + } + if (p === '/api/admin/snapshot/send' && req.method === 'POST') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const r = await snapshot.post(); return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/admin/site' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, { site: siteConfig() }); + } + // -- profit and loss from the chain index: volume, platform fees, member payouts, + // pass-ups, per period (by block: ~43,200 Polygon blocks a day), plus the fee + // wallets' live balances and an admin-entered fixed monthly cost + if (p === '/api/admin/pnl' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const days = Math.max(0, Number(u.searchParams.get('days') || 30)); + let latest = 0; try { latest = parseInt(await chain.rpc('eth_blockNumber', []), 16); } catch (e) {} + const fromBlock = days ? latest - Math.round(days * 43200) : 0; + const evs = chain.recentEvents(1e9).filter(e => !days || e.block >= fromBlock); + const sum = (list, f) => list.reduce((n, e) => n + BigInt(f(e) || '0'), 0n); + const purchases = evs.filter(e => e.type === 'Purchase'); + const tier = evs.filter(e => e.type === 'TierPaid'); + const admin = evs.filter(e => e.type === 'AdminPaid'); + const passed = evs.filter(e => e.type === 'PassedUp'); + const byTier = {}; + for (const t of [1, 2, 3]) byTier[t] = sum(tier.filter(e => e.tier === t), e => e.amountWei).toString(); + const byPkg = {}; + for (const e of purchases) { const k = '$' + Math.round(e.priceCents / 100); byPkg[k] = (byPkg[k] || 0) + 1; } + let polUsd = 0; try { const cat = await chain.catalog(); const pk = (cat.products || cat).find(x => x.costWei); if (pk) polUsd = (pk.priceCents / 100) / (Number(BigInt(pk.costWei)) / 1e18); } catch (e) {} + const wallets = { feeA: '0x7627fc78876948ac9d95c1c9eb061e7d6d647b70', feeB: '0x8b7d33849a2c4d92c985be31e46dc564ba901ad2', engine: burner.status().address || null }; + const balances = {}; + for (const [k, a] of Object.entries(wallets)) { if (!a) continue; try { balances[k] = BigInt(await chain.rpc('eth_getBalance', [a, 'latest'])).toString(); } catch (e) { balances[k] = null; } } + return json(res, 200, { days, fromBlock, latest, polUsd, + purchases: { count: purchases.length, volumeWei: sum(purchases, e => e.paidWei).toString(), usdCents: purchases.reduce((n, e) => n + (e.priceCents || 0), 0), byPackage: byPkg }, + platformWei: sum(admin, e => e.amountWei).toString(), memberPayoutsWei: sum(tier, e => e.amountWei).toString(), byTier, + passedUp: { count: passed.length, unqualified: passed.filter(e => e.reason === 'unqualified').length, sendFailed: passed.filter(e => e.reason === 'send-failed').length }, + wallets, balances, fixedMonthlyUsd: Number(siteConfig().pnlFixedMonthlyUsd) || 0, burner: burner.status() }); + } + if (p === '/api/admin/burner' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, burner.status()); + } + if (p === '/api/admin/burner/run' && req.method === 'POST') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, await burner.tick()); + } + + // -- admin (Bearer ADMIN_PASSWORD, or the /admin portal session) + if (p === '/api/admin/burns' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, { pending: await ads.pendingBurns() }); + } + if (p === '/api/admin/burns/mark' && req.method === 'POST') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + const r = await ads.markBurned(b.id, b.tx); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/admin/rates' && req.method === 'PATCH') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + return json(res, 200, { ok: true, rates: ads.setRates(b) }); + } + + if (p === '/api/admin/site' && req.method === 'PATCH') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + const cur = siteConfig(); + fs.writeFileSync(SITE_FILE, JSON.stringify(Object.assign(cur, b), null, 2)); + return json(res, 200, { ok: true, site: siteConfig() }); + } + if (p === '/api/admin/chain' && req.method === 'PATCH') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + const file = path.join(DATA_DIR, 'config.json'); + let cur = {}; try { cur = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) {} + fs.writeFileSync(file, JSON.stringify(Object.assign(cur, b), null, 2)); + chain.reloadConfig(); + return json(res, 200, { ok: true, config: chain.getConfig() }); + } + + // -- pages (HEAD answered like GET so link previewers and crawlers see 200; Node drops the body) + if (req.method === 'GET' || req.method === 'HEAD') { + if (p === '/') return sendFile(res, path.join(PUBLIC_DIR, 'index.html')); + if (p === '/ledger') return sendFile(res, path.join(PUBLIC_DIR, 'ledger.html')); + if (p === '/contract') return sendFile(res, path.join(PUBLIC_DIR, 'contract.html')); + if (p === '/terms') return sendFile(res, path.join(PUBLIC_DIR, 'terms.html')); + if (p === '/privacy') return sendFile(res, path.join(PUBLIC_DIR, 'privacy.html')); + if (p === '/disclaimer') return sendFile(res, path.join(PUBLIC_DIR, 'disclaimer.html')); + if (p === '/my') return sendFile(res, path.join(PUBLIC_DIR, 'my.html')); + if (p === '/admin') return sendFile(res, path.join(PUBLIC_DIR, 'admin.html')); + if (p === '/shorts') return sendFile(res, path.join(PUBLIC_DIR, 'shorts.html')); + if (p === '/plays') return sendFile(res, path.join(PUBLIC_DIR, 'plays.html')); + if (p === '/partners') return sendFile(res, path.join(PUBLIC_DIR, 'partners.html')); // site-owner kit (Marty, 2026-09-12) + if (p === '/earning') return sendFile(res, path.join(PUBLIC_DIR, 'earning.html')); // member guide: how earning works (2026-09-12) + // -- badge share page + image: /b// (OG preview = the member's composed badge) (Marty, 2026-09-13) + m = /^\/badge-img\/([a-z0-9_]{3,20})\/(payouts|firstBuyer|level2|level3)\.jpg$/.exec(p); + if (m) return sendFile(res, path.join(UPLOADS_DIR, 'badge-' + m[1] + '-' + m[2] + '.jpg')); + m = /^\/b\/([a-z0-9_]{3,20})\/(payouts|firstBuyer|level2|level3)$/i.exec(p); + if (m) { + const un = m[1].toLowerCase(), key = m[2]; + const a = await accounts.byUsername(un); + const file = path.join(UPLOADS_DIR, 'badge-' + un + '-' + key + '.jpg'); + if (!a || !fs.existsSync(file) || !(await ads.milestonesOf(a.email)).includes(key)) { res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' })); return res.end('Not found'); } + const [label, sub] = BADGE_META[key]; + const esc = t => String(t || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const url = 'https://linkspin-test.saasy.top/b/' + un + '/' + key, img = 'https://linkspin-test.saasy.top/badge-img/' + un + '/' + key + '.jpg'; + const title = '@' + un + ' unlocked ' + label + ' on LinkSpin'; + const desc = label + ': ' + sub + '. LinkSpin pays sponsors in the same transaction, on-chain. Join @' + un + '\u2019s line free.'; + const html = '' + esc(title) + '' + + '' + + '' + + '' + + '' + + '' + + '
      ' + esc(label) + ' badge for @' + esc(un) + '

      @' + esc(un) + ' unlocked ' + label + '

      ' + esc(sub.charAt(0).toUpperCase() + sub.slice(1)) + '. On LinkSpin every ad package that sells pays the sponsor in the same transaction, straight to their wallet, on-chain.

      ' + + 'Join @' + esc(un) + '\u2019s line free

      Advertising with a performance referral program. Not an investment; no income is guaranteed.

      ' + + ''; + res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' })); + return res.end(html); + } + // -- blog: server-rendered so crawlers get real HTML + metadata (Marty, 2026-09-12) + if (p === '/blog' || p === '/blog/' || /^\/blog\/page\/\d+$/.test(p) || /^\/blog\/tag\/[^/]+$/.test(p)) { + const posts = await blog.listPublished(); + const pg = (m = /^\/blog\/page\/(\d+)$/.exec(p)) ? Number(m[1]) : 1; + const tag = (m = /^\/blog\/tag\/([^/]+)$/.exec(p)) ? decodeURIComponent(m[1]).toLowerCase() : null; + res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' })); + return res.end(blog.renderIndex(posts, pg, tag)); + } + m = /^\/handout\/([a-z0-9_]{3,20})$/i.exec(p); + if (m) { const h = await toolkit.handout(m[1].toLowerCase()); if (!h) { res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' })); return res.end('Not found'); } res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' })); return res.end(h); } + if (p === '/leaderboard') { res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=60' })); return res.end(await leaderboard.renderPage()); } + if (p === '/whats-new') { res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=120' })); return res.end(releases.renderPage()); } + if (p === '/blog/feed.xml') { res.writeHead(200, baseHeaders({ 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'public, max-age=900' })); return res.end(blog.rss(await blog.listPublished())); } + if (p === '/sitemap.xml') { res.writeHead(200, baseHeaders({ 'Content-Type': 'application/xml; charset=utf-8', 'Cache-Control': 'public, max-age=3600' })); return res.end(blog.sitemap(await blog.listPublished())); } + if (p === '/robots.txt') { res.writeHead(200, baseHeaders({ 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=3600' })); return res.end(blog.robots()); } + m = /^\/blog\/([a-z0-9-]{1,80})$/.exec(p); + if (m) { + const post = await blog.get(m[1]); + const preview = !!(post && post.status !== 'published' && isAdmin(req)); // admins can open a draft at its real URL + if (!post || (post.status !== 'published' && !preview)) { res.writeHead(404, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); return res.end(blog.renderIndex(await blog.listPublished(), 1, null).replace('', '<title>Not found | ')); } + if (!preview && req.method === 'GET') blog.bumpViews(post.slug); + const related = blog.relatedFor(post, await blog.listPublished()); + res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': preview ? 'no-store' : 'public, max-age=300' })); + return res.end(blog.renderPost(post, related)); + } + if (p === '/wallets') return sendFile(res, path.join(PUBLIC_DIR, 'wallets.html')); + if (p === '/launch') return sendFile(res, path.join(PUBLIC_DIR, 'launch.html')); + if (/^\/view\/[a-f0-9]{32}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'view.html')); + m = /^\/uploads\/([a-z0-9]{24}\.(?:png|jpg|webp|gif|mp4|webm))$/.exec(p); + if (m) return sendFile(res, path.join(UPLOADS_DIR, m[1])); + if (/^\/tx\/0x[0-9a-fA-F]{64}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'tx.html')); + m = /^\/wall\/([A-Za-z0-9_]{1,20})$/.exec(p); + if (m) { // server-inject per-member OG tags so shared bio links preview correctly (crawlers don't run JS) + try { + const tok = m[1].toLowerCase(); + let a = await accounts.byUsername(tok); if (!a) a = await accounts.byCode(tok); + let html = fs.readFileSync(path.join(PUBLIC_DIR, 'wall.html'), 'utf8'); + if (a) { + const nm = a.username ? '@' + a.username : 'member #' + (a.memberId || 0); + const esc = t => String(t || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const title = nm + ' on LinkSpin'; + const desc = a.bio ? esc(a.bio).slice(0, 200) : 'Join ' + nm + '’s line on LinkSpin — instant on-chain ad payouts, free to join.'; + const img = a.avatarUrl && /^https:/.test(a.avatarUrl) ? a.avatarUrl : 'https://linkspin-test.saasy.top/banners/iap-hero-1200x630.png'; + const url = 'https://linkspin-test.saasy.top/wall/' + (a.username || a.code); + const og = [ + '<meta property="og:type" content="profile">', + '<meta property="og:site_name" content="LinkSpin">', + '<meta property="og:url" content="' + url + '">', + '<meta property="og:title" content="' + esc(title) + '">', + '<meta property="og:description" content="' + desc + '">', + '<meta property="og:image" content="' + esc(img) + '">', + '<meta name="twitter:card" content="summary_large_image">', + '<meta name="twitter:title" content="' + esc(title) + '">', + '<meta name="twitter:description" content="' + desc + '">', + '<meta name="twitter:image" content="' + esc(img) + '">', + '<meta name="description" content="' + desc + '">', + '<link rel="canonical" href="' + url + '">' + ].join('\n'); + html = html.replace('<title>Banner wall | LinkSpin', '' + esc(title) + '').replace('', og); + } + res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' })); + return res.end(html); + } catch (e) { return sendFile(res, path.join(PUBLIC_DIR, 'wall.html')); } + } + const safe = path.normalize(p).replace(/^([.\\/])+/, ''); + const file = path.join(PUBLIC_DIR, safe); + if (file.startsWith(PUBLIC_DIR) && fs.existsSync(file) && fs.statSync(file).isFile()) return sendFile(res, file); + } + + res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' })); + res.end('Not found'); + } catch (e) { + console.error('request error', req.url, e.message); + try { json(res, 500, { error: 'server error' }); } catch (_) {} + } +}); +boot().then(() => { + server.listen(PORT, () => console.log(`LinkSpin site on :${PORT} — chain: ${chain.getConfig().chainName} — store: ${db.enabled() ? 'MySQL' : 'volume JSON'}`)); +}).catch(e => { console.error('boot failed:', e.message); process.exit(1); }); diff --git a/snapshot.js b/snapshot.js new file mode 100644 index 0000000..6d2310a --- /dev/null +++ b/snapshot.js @@ -0,0 +1,89 @@ +// Growth snapshot (Marty, 2026-09-15): once a day, a short "how the site is doing" post in the Telegram +// payments feed (and the shared payments topic), so members watching the payout lines also see the +// whole picture: sign-ups, purchases, POL paid out, campaigns posted, ads viewed. Every number comes +// from the same tables and chain index the dashboards read. Settings: snapshotEnabled (1/0), +// snapshotHourUtc (default 14 = 9 AM Central), snapshotTargets (feed,echo). State: snapshot-state.json. +const fs = require('fs'); +const path = require('path'); + +let X = {}; +const DAY = 86400000; +function init(opts) { X = opts || {}; } +const STATE = () => path.join(X.dataDir, 'snapshot-state.json'); +function state() { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')); } catch (e) { return {}; } } +function setState(s) { try { fs.writeFileSync(STATE(), JSON.stringify(s)); } catch (e) {} } +const n = v => Number(v || 0).toLocaleString('en-US'); +const pol = w => { try { return (Number(BigInt(w) / (10n ** 16n)) / 100).toLocaleString('en-US', { maximumFractionDigits: 0 }); } catch (e) { return '0'; } }; + +async function gather(now = Date.now()) { + const since = now - DAY, prior = now - 2 * DAY; + const s = { signups: 0, signupsPrior: 0, members: 0, wallets: 0, payoutsOn: 0, seen24h: 0, + campaigns: 0, advertisers: 0, active: 0, activeAdvertisers: 0, imps: 0, viewers: 0, views: 0, + buys: 0, buysPrior: 0, usd: 0, activations: 0, paidWei: 0n, lifeBuys: 0, lifePaidWei: 0n, lifePayouts: 0 }; + if (X.db && X.db.enabled()) { + const q = (a, b) => X.db.q(a, b || []); + const a = (await q('SELECT COUNT(*) total, SUM(created>=?) s24, SUM(created>=? AND created0) payouts, SUM(last_seen>=?) seen FROM accounts', [since, prior, since, since]))[0] || {}; + s.members = Number(a.total || 0); s.signups = Number(a.s24 || 0); s.signupsPrior = Number(a.sPrior || 0); s.wallets = Number(a.wallet || 0); s.payoutsOn = Number(a.payouts || 0); s.seen24h = Number(a.seen || 0); + const c = (await q('SELECT COUNT(*) n, COUNT(DISTINCT owner_email) owners FROM campaigns WHERE house=0 AND created>=?', [since]))[0] || {}; + s.campaigns = Number(c.n || 0); s.advertisers = Number(c.owners || 0); + const ac = (await q("SELECT COUNT(*) n, COUNT(DISTINCT owner_email) owners FROM campaigns WHERE house=0 AND status='active'"))[0] || {}; + s.active = Number(ac.n || 0); s.activeAdvertisers = Number(ac.owners || 0); + // impressions in the last 24 hours: camp_hours is keyed by UTC day + hour + const d0 = new Date(since).toISOString().slice(0, 10), h0 = new Date(since).getUTCHours(), d1 = new Date(now).toISOString().slice(0, 10); + const im = (await q('SELECT SUM(n) imps FROM camp_hours WHERE (day=? AND hour>=?) OR (day>? AND day<=?)', [d0, h0, d0, d1]))[0] || {}; + s.imps = Number(im.imps || 0); + const v = (await q('SELECT COUNT(*) viewers, SUM(views) views FROM daily_views WHERE day=? AND views>0', [d1]))[0] || {}; + s.viewers = Number(v.viewers || 0); s.views = Number(v.views || 0); + } + try { + for (const ev of X.chain.recentEvents(1e9)) { + if (ev.type === 'Purchase') { s.lifeBuys++; if (ev.ts >= since) { s.buys++; s.usd += ev.priceCents / 100; } else if (ev.ts >= prior) s.buysPrior++; } + else if (ev.type === 'MemberActivated' && ev.ts >= since) s.activations++; + else if (ev.type === 'TierPaid' || ev.type === 'AwardPaid') { s.lifePayouts++; s.lifePaidWei += BigInt(ev.amountWei); if (ev.ts >= since) s.paidWei += BigInt(ev.amountWei); } + } + // the index keeps a bounded event list; lifetime totals come from its running tally when present + const t = X.chain.totals ? X.chain.totals() : null; + if (t) { if (t.purchases) s.lifeBuys = Number(t.purchases); if (t.payoutWei) s.lifePaidWei = BigInt(t.payoutWei); if (t.payouts) s.lifePayouts = Number(t.payouts); } + } catch (e) {} + return s; +} + +function compose(s, cta) { + const trend = (cur, prev, what) => cur > prev ? ' (up from ' + n(prev) + ' ' + what + ')' : cur < prev ? ' (' + n(prev) + ' ' + what + ')' : ''; + const L = []; + L.push('\u{1F4C8} LinkSpin · 24-hour snapshot'); + L.push('\u{1F465} Sign-ups: ' + n(s.signups) + '' + trend(s.signups, s.signupsPrior, 'the day before') + ' · ' + n(s.activations) + ' switched on payouts'); + L.push('\u{1F9FE} Purchases: ' + n(s.buys) + ' for $' + n(Math.round(s.usd)) + trend(s.buys, s.buysPrior, 'the day before') + ' · ' + pol(s.paidWei) + ' POL paid to members in the same transactions'); + L.push('\u{1F4E3} Campaigns: ' + n(s.campaigns) + ' posted by ' + n(s.advertisers) + ' advertiser' + (s.advertisers === 1 ? '' : 's') + ' · ' + n(s.active) + ' running now · ' + n(s.imps) + ' ad impressions served'); + L.push('\u{1F440} Earning: ' + n(s.viewers) + ' members viewed ads today · ' + n(s.seen24h) + ' signed in'); + L.push('\u{1F3C1} So far: ' + n(s.members) + ' members · ' + n(s.wallets) + ' wallets linked · ' + n(s.payoutsOn) + ' with payouts on · ' + n(s.lifeBuys) + ' purchases · ' + pol(s.lifePaidWei) + ' POL paid out in ' + n(s.lifePayouts) + ' payouts, every one on the public ledger'); + L.push('Live ledger' + (cta ? ' · Join free' : '')); + return L.join('\n'); +} + +async function post() { + const sc = X.siteConfig(); + if (!sc.telegramBotToken) return { error: 'No Telegram bot token set.' }; + const s = await gather(); const text = compose(s, sc.telegramCtaUrl); + const targets = String(sc.snapshotTargets || 'feed,echo').split(',').map(x => x.trim()); + let sent = 0; + if (targets.includes('feed') && sc.telegramChatId) { if (await X.send(sc.telegramChatId, text, sc.telegramTopicId)) sent++; } + if (targets.includes('echo') && sc.telegramEchoChatId) { if (await X.send(sc.telegramEchoChatId, text, sc.telegramEchoTopicId)) sent++; } + const st = state(); st.lastPost = Date.now(); st.lastDay = new Date().toISOString().slice(0, 10); setState(st); + return { ok: true, sent, text }; +} + +// every 10 minutes: post once a day at or after snapshotHourUtc (a restart never double-posts) +async function tick() { + const sc = X.siteConfig(); + if (String(sc.snapshotEnabled || '1') !== '1' || !sc.telegramBotToken) return; + const hour = Number(sc.snapshotHourUtc == null || sc.snapshotHourUtc === '' ? 14 : sc.snapshotHourUtc); + const now = new Date(); const day = now.toISOString().slice(0, 10); + if (now.getUTCHours() < hour) return; + if (state().lastDay === day) return; + await post(); +} + +async function preview() { const sc = X.siteConfig(); return compose(await gather(), sc.telegramCtaUrl); } + +module.exports = { init, gather, compose, post, tick, preview }; diff --git a/spaces.js b/spaces.js new file mode 100644 index 0000000..9a46f46 --- /dev/null +++ b/spaces.js @@ -0,0 +1,59 @@ +// DigitalOcean Spaces (S3-compatible) uploader — hand-rolled AWS SigV4 PUT so +// large media (video) lives in object storage instead of the Coolify volume. +// Zero-dependency (crypto only). FEATURE-FLAGGED: inert unless all of +// DO_SPACES_KEY / DO_SPACES_SECRET / DO_SPACES_BUCKET / DO_SPACES_REGION are set. +const crypto = require('crypto'); +const https = require('https'); + +function enabled() { + return !!(process.env.DO_SPACES_KEY && process.env.DO_SPACES_SECRET + && process.env.DO_SPACES_BUCKET && process.env.DO_SPACES_REGION); +} +const sha256hex = b => crypto.createHash('sha256').update(b).digest('hex'); +const hmac = (key, s) => crypto.createHmac('sha256', key).update(s).digest(); + +// PUT one object, public-read. Returns the public URL. Rejects on non-2xx. +function put(key, body, contentType) { + return new Promise((resolve, reject) => { + if (!enabled()) return reject(new Error('spaces-disabled')); + const region = process.env.DO_SPACES_REGION; + const bucket = process.env.DO_SPACES_BUCKET; + const host = bucket + '.' + region + '.digitaloceanspaces.com'; + const path = '/' + key.replace(/^\/+/, ''); + const now = new Date(); + const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ''); // YYYYMMDDTHHMMSSZ + const dateStamp = amzDate.slice(0, 8); + const payloadHash = sha256hex(body); + const signed = 'content-type;host;x-amz-acl;x-amz-content-sha256;x-amz-date'; + const canonicalHeaders = + 'content-type:' + contentType + '\n' + + 'host:' + host + '\n' + + 'x-amz-acl:public-read\n' + + 'x-amz-content-sha256:' + payloadHash + '\n' + + 'x-amz-date:' + amzDate + '\n'; + const canonicalReq = ['PUT', path, '', canonicalHeaders, signed, payloadHash].join('\n'); + const scope = dateStamp + '/' + region + '/s3/aws4_request'; + const toSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256hex(canonicalReq)].join('\n'); + const kDate = hmac('AWS4' + process.env.DO_SPACES_SECRET, dateStamp); + const kRegion = hmac(kDate, region); + const kService = hmac(kRegion, 's3'); + const kSigning = hmac(kService, 'aws4_request'); + const signature = crypto.createHmac('sha256', kSigning).update(toSign).digest('hex'); + const auth = 'AWS4-HMAC-SHA256 Credential=' + process.env.DO_SPACES_KEY + '/' + scope + + ', SignedHeaders=' + signed + ', Signature=' + signature; + const req = https.request({ host, path, method: 'PUT', timeout: 30000, headers: { + 'Content-Type': contentType, 'Content-Length': body.length, 'x-amz-acl': 'public-read', + 'x-amz-content-sha256': payloadHash, 'x-amz-date': amzDate, Authorization: auth } }, + res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + const base = process.env.DO_SPACES_CDN || ('https://' + host); + resolve(base.replace(/\/$/, '') + path); + } else reject(new Error('spaces ' + res.statusCode + ': ' + d.slice(0, 200))); + }); }); + req.on('error', reject); + req.on('timeout', () => req.destroy(new Error('spaces timeout'))); + req.end(body); + }); +} + +module.exports = { enabled, put }; diff --git a/syndicate.js b/syndicate.js new file mode 100644 index 0000000..2ef8ffb --- /dev/null +++ b/syndicate.js @@ -0,0 +1,71 @@ +// Blog social syndication (Marty, 2026-09-13): when an article goes from draft to published, push it +// through Blotato to X (@cryptoteambuild, account 7998) and Instagram (marketingwithmarty, 16261). +// Key: DATA_DIR/blotato.key (never logged). Record per slug in DATA_DIR/blog-syndication.json so a +// republish never double-posts; admin can see the result on the article row. No Facebook (Marty's call). +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +let DATA_DIR = null, PUBLIC_DIR = null, UPLOADS_DIR = null; +const SITE = 'https://linkspin-test.saasy.top'; +const ACCOUNTS = { x: { id: 7998, target: { targetType: 'twitter' }, platform: 'twitter' }, instagram: { id: 16261, target: { targetType: 'instagram' }, platform: 'instagram' } }; +const FILE = () => path.join(DATA_DIR, 'blog-syndication.json'); +function init(opts) { DATA_DIR = opts.dataDir; PUBLIC_DIR = opts.publicDir; UPLOADS_DIR = opts.uploadsDir; } +function key() { try { return fs.readFileSync(path.join(DATA_DIR, 'blotato.key'), 'utf8').trim(); } catch (e) { return ''; } } +function enabled() { return !!key(); } +function log() { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { return {}; } } +function saveLog(l) { try { fs.writeFileSync(FILE(), JSON.stringify(l, null, 1)); } catch (e) {} } +function statusOf(slug) { return log()[slug] || null; } + +function blotato(pathname, body) { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const req = https.request({ hostname: 'backend.blotato.com', path: '/v2' + pathname, method: 'POST', headers: { 'blotato-api-key': key(), 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }, timeout: 60000 }, res => { + let out = ''; res.on('data', c => out += c); res.on('end', () => { let j = null; try { j = JSON.parse(out); } catch (e) {} if (res.statusCode >= 200 && res.statusCode < 300) resolve(j || {}); else reject(new Error('Blotato ' + res.statusCode + ': ' + out.slice(0, 200))); }); + }); + req.on('error', reject); req.on('timeout', () => req.destroy(new Error('timeout'))); req.write(data); req.end(); + }); +} +// the cover goes through Blotato's media store first (a data URL, like the shorts pipeline), so the +// post never depends on our origin being fetchable from their side; https covers are passed as-is +async function coverMedia(post) { + const c = post.cover || '/banners/iap-hero-1200x630.png'; + if (/^https?:\/\//.test(c)) return c; + let file = null; + if (c.startsWith('/uploads/')) file = path.join(UPLOADS_DIR, c.slice('/uploads/'.length)); + else if (PUBLIC_DIR) file = path.join(PUBLIC_DIR, c.replace(/^\//, '')); + let buf = null; try { buf = fs.readFileSync(file); } catch (e) {} + if (!buf || buf.length > 4.3e6) { try { buf = fs.readFileSync(path.join(PUBLIC_DIR, 'banners/iap-hero-1200x630.png')); } catch (e) { return SITE + c; } } + const mime = /\.png$/i.test(file || '') ? 'image/png' : /\.webp$/i.test(file || '') ? 'image/webp' : 'image/jpeg'; + const r = await blotato('/media', { url: 'data:' + mime + ';base64,' + buf.toString('base64') }); + return (r && r.url) || (SITE + c); +} +function xText(post) { + const url = SITE + '/blog/' + post.slug; + const room = 280 - 23 - 2; // t.co link + spacing + let t = post.title + '\n\n' + (post.excerpt || ''); + if (t.length > room) t = t.slice(0, room - 1).replace(/\s+\S*$/, '') + '…'; + return t + '\n\n' + url; +} +function igText(post) { + return post.title + '\n\n' + (post.excerpt || '') + '\n\nRead it: linkspin-test.saasy.top/blog/' + post.slug + '\n\n#LinkSpin #advertising #teambuilding #polygon #crypto'; +} +// returns { x: {ok, id|error}, instagram: {...} }; never throws, never posts twice for one slug +async function publish(post, opts) { + const l = log(); const prev = l[post.slug]; + if (prev && prev.done && !(opts && opts.force)) return prev; + if (!enabled()) { const r = { done: false, at: Date.now(), error: 'no Blotato key on the server' }; l[post.slug] = r; saveLog(l); return r; } + let media; try { media = await coverMedia(post); } catch (e) { const r = { done: false, at: Date.now(), error: 'cover upload: ' + String(e.message || e).slice(0, 160) }; l[post.slug] = r; saveLog(l); console.log('blog syndication', post.slug, r.error); return r; } + const out = { at: Date.now(), done: true, results: {} }; + for (const [name, a] of Object.entries(ACCOUNTS)) { + if (prev && prev.results && prev.results[name] && prev.results[name].ok) { out.results[name] = prev.results[name]; continue; } // retry only what failed + try { + const text = name === 'x' ? xText(post) : igText(post); + const r = await blotato('/posts', { post: { accountId: String(a.id), content: { text, mediaUrls: [media], platform: a.platform }, target: a.target } }); + out.results[name] = { ok: true, id: (r && (r.postSubmissionId || r.id)) || null, at: Date.now() }; + } catch (e) { out.results[name] = { ok: false, error: String(e.message || e).slice(0, 200), at: Date.now() }; out.done = false; } + } + l[post.slug] = out; saveLog(l); + console.log('blog syndication', post.slug, JSON.stringify(Object.fromEntries(Object.entries(out.results).map(([k, v]) => [k, v.ok ? 'ok' : v.error])))); + return out; +} +module.exports = { init, enabled, publish, statusOf, log }; diff --git a/tank.js b/tank.js new file mode 100644 index 0000000..8df43b4 --- /dev/null +++ b/tank.js @@ -0,0 +1,264 @@ +'use strict'; +// Holding tank (Marty, 2026-09-11): free members who arrived with no sponsor wait +// here, and a member who has bought their own $20+ package can adopt one: +// first come, at most two open adoptions at a time, an adoption falls back into +// the tank after 7 days if the person never linked a wallet or bought, and a +// person can be adopted twice at most before they stay wherever they are. +// Members can also release one of their own free referrals into the tank +// (pay it forward). The contract binds sponsor at first purchase, so every +// hand-off here is a site record until then. +const fs = require('fs'); +const path = require('path'); +const db = require('./db'); + +const CAP_OPEN = 2; // open adoptions per adopter +const TTL_MS = 7 * 86400000; // an adoption's window to convert +const MAX_ADOPTIONS = 2; // per adoptee, lifetime (dropped adoptions count too, since 2026-09-13) +const HOLD_MS = 3 * 24 * 3600 * 1000; // an adoption is a commitment: no releasing someone you picked up less than 3 days ago (Marty, 2026-09-13) +const MIN_OWN_BUY_CENTS = 2000; // adopter must have bought a $20+ package themselves +const WARN_DAYS = 10; // dormant lead: sponsor warned after this many days without contact +const RESCUE_DAYS = 14; // ...and the lead moves to the tank after this many (warning at least 4 days old) + +let DATA_DIR = '.', accounts, chain, messages, mailer, siteUrl = 'https://linkspin-test.saasy.top'; + +const J = { + db: { v: 1, nextId: 1, adoptions: [], marks: {} }, + FILE: () => path.join(DATA_DIR, 'tank.json'), + load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} }, + save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }, + async add(a) { const row = Object.assign({ id: this.db.nextId++ }, a); this.db.adoptions.push(row); this.save(); return row; }, + async open(adopter) { return this.db.adoptions.filter(x => x.status === 'open' && (!adopter || x.adopter === adopter)); }, + async countFor(adoptee) { return this.db.adoptions.filter(x => x.adoptee === adoptee && x.status !== 'released').length; }, + async setStatus(id, status) { const x = this.db.adoptions.find(r => r.id === id); if (x) { x.status = status; x.closed = Date.now(); this.save(); } }, + async recent(n) { return this.db.adoptions.slice(-(n || 100)).reverse(); }, + async hasRecord(adoptee) { return this.db.adoptions.some(x => x.adoptee === adoptee && x.status !== 'released'); }, + async mark(lead) { return this.db.marks[lead] || null; }, + async setMark(lead, f) { this.db.marks[lead] = Object.assign(this.db.marks[lead] || {}, f); this.save(); } +}; +const D = { + async add(a) { + const r = await db.q('INSERT INTO adoptions (adoptee,adopter,ts,expires,status,note) VALUES (?,?,?,?,?,?)', [a.adoptee, a.adopter, a.ts, a.expires, a.status, a.note || null]); + return Object.assign({ id: r.insertId }, a); + }, + async open(adopter) { + const rows = adopter ? await db.q("SELECT * FROM adoptions WHERE status='open' AND adopter=?", [adopter]) : await db.q("SELECT * FROM adoptions WHERE status='open'"); + return rows.map(rowA); + }, + async countFor(adoptee) { const r = await db.q("SELECT COUNT(*) n FROM adoptions WHERE adoptee=? AND status<>'released'", [adoptee]); return Number(r[0].n); }, + async setStatus(id, status) { await db.q('UPDATE adoptions SET status=?, closed=? WHERE id=?', [status, Date.now(), Number(id)]); }, + async recent(n) { return (await db.q('SELECT * FROM adoptions ORDER BY id DESC LIMIT ?', [Number(n) || 100])).map(rowA); }, + async hasRecord(adoptee) { const r = await db.q("SELECT COUNT(*) n FROM adoptions WHERE adoptee=? AND status<>'released'", [adoptee]); return Number(r[0].n) > 0; }, + async mark(lead) { const r = await db.q('SELECT * FROM lead_marks WHERE `lead`=?', [lead]); return r[0] ? { sponsor: r[0].sponsor, contacted: Number(r[0].contacted_ts || 0), warned: Number(r[0].warned_ts || 0) } : null; }, + async setMark(lead, f) { + const cur = (await this.mark(lead)) || {}; const m = Object.assign(cur, f); + await db.q('INSERT INTO lead_marks (`lead`,sponsor,contacted_ts,warned_ts) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE sponsor=VALUES(sponsor), contacted_ts=VALUES(contacted_ts), warned_ts=VALUES(warned_ts)', [lead, m.sponsor || null, m.contacted || null, m.warned || null]); + } +}; +const rowA = r => ({ id: r.id, adoptee: r.adoptee, adopter: r.adopter, ts: Number(r.ts), expires: Number(r.expires), status: r.status, note: r.note || null, closed: r.closed ? Number(r.closed) : null }); +const impl = () => db.enabled() ? D : J; + +function init(opts) { DATA_DIR = opts.dataDir; accounts = opts.accounts; chain = opts.chain; messages = opts.messages; mailer = opts.mailer; if (opts.site) siteUrl = opts.site; J.load(); } + +const mask = e => String(e || '').replace(/^(.).*(@.*)$/, '$1***$2'); +const nameOf = a => a.username ? '@' + a.username : (a.memberId ? 'member #' + a.memberId : mask(a.email)); +const inTank = a => !a.sponsorRef && !a.memberId; // arrived with no sponsor (or fell back), still free + +// waiting list: newest sign-in first so a live one is easy to spot +async function waiting() { + const all = await accounts.listAll(2000); + return all.filter(inTank).map(a => ({ email: a.email, name: nameOf(a), username: a.username || null, joined: a.created, lastSeen: a.lastSeen || 0, wallet: false })) + .sort((a, b) => (b.lastSeen || b.joined) - (a.lastSeen || a.joined)); +} + +// has this member bought a $20+ package themselves? (the qualifying-for-yourself buy) +function hasOwnBuy(memberId) { + if (!memberId) return false; + for (const ev of chain.recentEvents(200000)) if (ev.type === 'Purchase' && ev.buyerId === memberId && Number(ev.priceCents) >= MIN_OWN_BUY_CENTS) return true; + return false; +} +async function eligibility(email) { + const a = await accounts.byEmail(email); + if (!a) return { ok: false, reason: 'Sign in first.' }; + if (!a.memberId) return { ok: false, reason: 'Switch on payouts and buy your first $20 package to adopt from the tank.' }; + if (!hasOwnBuy(a.memberId)) return { ok: false, reason: 'Buy your own $20 or more package first. Adopting is for members who have made that move themselves.' }; + const open = await impl().open(a.email); + if (open.length >= CAP_OPEN) return { ok: false, reason: 'You have ' + CAP_OPEN + ' open adoptions. Help one of them link a wallet or buy, and a slot frees up.', full: true }; + return { ok: true, account: a, open }; +} + +async function view(email) { + const e = String(email || '').toLowerCase(); + const el = await eligibility(e); + const mine = []; + for (const ad of await impl().open(e)) { + const a = await accounts.byEmail(ad.adoptee); + mine.push({ id: ad.id, name: a ? nameOf(a) : mask(ad.adoptee), email: ad.adoptee, ts: ad.ts, expires: ad.expires, lastSeen: a ? (a.lastSeen || 0) : 0, wallet: !!(a && a.address), address: (a && a.address) || null, bought: !!(a && a.memberId) }); + } + return { eligible: el.ok, reason: el.ok ? '' : el.reason, cap: CAP_OPEN, ttlDays: TTL_MS / 86400000, waiting: await waiting(), mine }; +} + +async function adopt(adopterEmail, who, note) { + const e = String(adopterEmail || '').toLowerCase(); + const el = await eligibility(e); + if (!el.ok) return { error: el.reason }; + const me = el.account; + const key = String(who || '').trim().toLowerCase().replace(/^@/, ''); + const list = await waiting(); + const target = list.find(w => (w.username && w.username.toLowerCase() === key) || w.email === key); + if (!target) return { error: 'That member is no longer in the tank.' }; + if (target.email === e) return { error: 'That is you.' }; + if (await impl().countFor(target.email) >= MAX_ADOPTIONS) return { error: 'That member has been adopted twice already and stays where they are.' }; + const token = me.username || me.code || String(me.memberId); + const r = await accounts.setSponsorRef(target.email, token); + if (r.error) return r; + const now = Date.now(); + const text = String(note || '').trim().slice(0, 600) || ('Hi, I am ' + nameOf(me) + '. You joined LinkSpin without a sponsor, so I picked you up from the holding tank. I will walk you through the first three steps whenever you are ready. Reply here.'); + const ad = await impl().add({ adoptee: target.email, adopter: e, ts: now, expires: now + TTL_MS, status: 'open', note: text }); + try { await messages.sendChat(me.memberId || 0, e, target.email, text); } catch (err) {} + if (mailer && mailer.hasKey()) { + try { + await mailer.send(target.email, nameOf(me) + ' is now your sponsor on LinkSpin', + 'You joined LinkSpin without a sponsor. ' + nameOf(me) + ' has picked you up from the holding tank and is your sponsor now, which means a real person to walk you through the first steps.\n\nTheir message:\n\n' + text + '\n\nReply in your member area: ' + siteUrl + '/my#messages\n\nLinkSpin'); + } catch (err) {} + } + return { ok: true, adoption: ad, name: target.name, adopterName: nameOf(me), adopteeName: target.name }; +} + +// pay it forward: give one of your own free referrals to the tank +async function release(ownerEmail, directEmail) { + const o = String(ownerEmail || '').toLowerCase(), d = String(directEmail || '').toLowerCase(); + const owner = await accounts.byEmail(o), direct = await accounts.byEmail(d); + if (!owner || !direct) return { error: 'No such member.' }; + const toks = [owner.code, owner.username, owner.memberId ? String(owner.memberId) : null].filter(Boolean).map(String); + if (!toks.includes(String(direct.sponsorRef || ''))) return { error: 'That member is not in your line.' }; + if (direct.memberId) return { error: 'That member has already bought; on-chain sponsorship cannot move.' }; + // picked up from the tank recently? then it is not theirs to drop yet (Bradley churned three in 80 seconds, 2026-09-13) + const openAds = (await impl().open(null)).filter(ad => ad.adoptee === d); + const young = openAds.find(ad => Date.now() - ad.ts < HOLD_MS); + if (young) { + const hrs = Math.ceil((HOLD_MS - (Date.now() - young.ts)) / 3600000); + return { error: 'You picked ' + nameOf(direct) + ' up from the tank ' + Math.max(1, Math.round((Date.now() - young.ts) / 3600000)) + ' hour(s) ago. An adoption is a commitment: message them, help them link a wallet, and if it goes nowhere you can release them in ' + hrs + ' hour(s).' }; + } + const r = await accounts.setSponsorRef(d, ''); + if (r.error) return r; + // an open adoption of this person closes as dropped, and a dropped adoption still counts toward their two + let closed = 0; + for (const ad of openAds) { await impl().setStatus(ad.id, 'dropped'); closed++; } + if (!closed) await impl().add({ adoptee: d, adopter: o, ts: Date.now(), expires: Date.now(), status: 'released', note: 'released to the tank' }); + return { ok: true, adopted: closed > 0, ownerName: nameOf(owner), memberName: nameOf(direct) }; +} + +// open adoptions past their window: converted ones close as done; the rest fall +// back into the tank unless the person has been adopted twice already +async function sweep() { + const now = Date.now(); + let done = 0, back = 0, kept = 0; + for (const ad of await impl().open(null)) { + if (ad.expires > now) continue; + const a = await accounts.byEmail(ad.adoptee); + if (!a) { await impl().setStatus(ad.id, 'expired'); continue; } + if (a.address || a.memberId) { await impl().setStatus(ad.id, 'done'); done++; continue; } + if (await impl().countFor(ad.adoptee) >= MAX_ADOPTIONS) { await impl().setStatus(ad.id, 'expired'); kept++; continue; } + await accounts.setSponsorRef(ad.adoptee, ''); + await impl().setStatus(ad.id, 'expired'); back++; + } + const r = await rescueSweep(); + return { done, back, kept, warned: r.warned, rescued: r.rescued, dead: r.dead }; +} + +// pay-it-forward gift record: the sponsor already sent POL wallet-to-wallet; we +// only log the hash and tell the recipient. Recipient must be in the giver's line. +async function recordGift(fromEmail, toEmail, tx, pol) { + const f = String(fromEmail || '').toLowerCase(), t = String(toEmail || '').toLowerCase(); + const giver = await accounts.byEmail(f), to = await accounts.byEmail(t); + if (!giver || !to) return { error: 'No such member.' }; + const toks = [giver.code, giver.username, giver.memberId ? String(giver.memberId) : null].filter(Boolean).map(String); + const adopted = (await impl().open(f)).some(ad => ad.adoptee === t); + if (!toks.includes(String(to.sponsorRef || '')) && !adopted) return { error: 'That member is not in your line.' }; + if (!/^0x[0-9a-fA-F]{64}$/.test(String(tx || ''))) return { error: 'Transaction hash missing.' }; + const amount = Number(pol) || 0; + const row = { adoptee: t, adopter: f, ts: Date.now(), expires: Date.now(), status: 'gift', note: 'PIF ' + amount + ' POL ' + tx }; + await impl().add(row); + const cc = chain.getConfig ? chain.getConfig() : {}; + const link = ((cc.explorer || 'https://polygonscan.com').replace(/\/+$/, '')) + '/tx/' + tx; + const text = nameOf(giver) + ' just sent ' + amount + ' POL to your wallet so you can buy your first package. It is already there: open Buy packages when you are ready. Proof: ' + link; + try { await messages.sendChat(giver.memberId || 0, f, t, text); } catch (e) {} + if (mailer && mailer.hasKey()) { try { await mailer.send(t, nameOf(giver) + ' sent you POL for your first LinkSpin package', text + '\n\n' + siteUrl + '/my#buy'); } catch (e) {} } + return { ok: true }; +} + +// ── dormant-lead rescue ── +// a free lead (no wallet, no purchase) whose sponsor has not written to them, on +// site or by marking "contacted", for WARN_DAYS gets a warning to the sponsor; +// at RESCUE_DAYS (warning at least 4 days old) the lead moves to the tank. +// Anyone with an adoption record is left to the tank's own window. +async function sponsorOf(acct) { + const t = String(acct.sponsorRef || ''); if (!t) return null; + return (await accounts.byUsername(t)) || (await accounts.byCode(t)) || (/^\d+$/.test(t) ? await accounts.byMemberId(Number(t)) : null) || null; +} +async function lastContact(sponsorEmail, lead) { + const m = await impl().mark(lead.email); + const chat = messages ? await messages.lastFrom(sponsorEmail, lead.email) : 0; + return Math.max(lead.created || 0, chat, (m && m.contacted) || 0); +} +// what the coach card shows the sponsor about each free direct +async function rescueInfo(sponsorEmail, lead) { + if (!lead || lead.memberId || lead.address) return null; + if (await impl().hasRecord(lead.email)) return null; + const since = await lastContact(sponsorEmail, lead); + const days = Math.floor((Date.now() - since) / 86400000); + const m = await impl().mark(lead.email); + return { days, warned: !!(m && m.warned), rescueInDays: Math.max(0, RESCUE_DAYS - days), unreached: days >= WARN_DAYS }; +} +async function markContacted(sponsorEmail, leadEmail) { + const s = String(sponsorEmail || '').toLowerCase(), l = String(leadEmail || '').toLowerCase(); + const owner = await accounts.byEmail(s), lead = await accounts.byEmail(l); + if (!owner || !lead) return { error: 'No such member.' }; + const toks = [owner.code, owner.username, owner.memberId ? String(owner.memberId) : null].filter(Boolean).map(String); + if (!toks.includes(String(lead.sponsorRef || ''))) return { error: 'That member is not in your line.' }; + await impl().setMark(l, { sponsor: s, contacted: Date.now(), warned: 0 }); + return { ok: true }; +} +async function rescueSweep() { + const now = Date.now(); + let warned = 0, rescued = 0, dead = 0; + for (const a of await accounts.listAll(5000)) { + if (a.memberId || a.address || !a.sponsorRef) continue; // bound, wallet, or already in the tank + if (await impl().hasRecord(a.email)) continue; // adopted / rescued before: the tank's window rules + const sp = await sponsorOf(a); + if (!sp) { // dead sponsor link: nobody can ever coach them, straight to the tank after a day + if (now - (a.created || 0) < 86400000) continue; + await accounts.setSponsorRef(a.email, ''); + await impl().add({ adoptee: a.email, adopter: 'dead:' + a.sponsorRef, ts: now, expires: now, status: 'rescued', note: 'sponsor link ' + a.sponsorRef + ' resolves to nobody' }); + dead++; continue; + } + if (sp.email === a.email) continue; + const since = await lastContact(sp.email, a); + const days = (now - since) / 86400000; + if (days < WARN_DAYS) continue; + const m = (await impl().mark(a.email)) || {}; + if (!m.warned || m.warned < since) { + await impl().setMark(a.email, { sponsor: sp.email, warned: now }); + if (mailer && mailer.hasKey()) { try { await mailer.send(sp.email, 'LinkSpin: you have not reached ' + nameOf(a), + nameOf(a) + ' joined through your link ' + Math.floor(days) + ' days ago and there is no message from you to them on the site.\n\nContact them, or mark "Contacted them" on their row in Members > My line if you reached them by phone or text. If nothing happens in the next ' + (RESCUE_DAYS - WARN_DAYS) + ' days they move to the holding tank so another member can help them.\n\n' + siteUrl + '/my#line'); } catch (e) {} } + warned++; continue; + } + if (days >= RESCUE_DAYS && now - m.warned >= (RESCUE_DAYS - WARN_DAYS) * 86400000) { + await accounts.setSponsorRef(a.email, ''); + await impl().add({ adoptee: a.email, adopter: sp.email, ts: now, expires: now, status: 'rescued', note: 'rescued from ' + nameOf(sp) + ' after ' + Math.floor(days) + ' days without contact' }); + if (mailer && mailer.hasKey()) { try { await mailer.send(sp.email, 'LinkSpin: ' + nameOf(a) + ' moved to the holding tank', + nameOf(a) + ' joined through your link ' + Math.floor(days) + ' days ago and was never contacted, so they have moved to the holding tank where another member can pick them up. Nothing on-chain changed. Your other referrals are unaffected.\n\n' + siteUrl + '/my#line'); } catch (e) {} } + rescued++; + } + } + return { warned, rescued, dead }; +} + +async function adminView() { + const recent = await impl().recent(200); + const names = {}; + for (const ad of recent) for (const em of [ad.adoptee, ad.adopter]) if (!(em in names)) { const a = await accounts.byEmail(em); names[em] = a ? nameOf(a) : em; } + return { waiting: await waiting(), adoptions: recent.map(ad => Object.assign({}, ad, { adopteeName: names[ad.adoptee], adopterName: names[ad.adopter] })), cap: CAP_OPEN, ttlDays: TTL_MS / 86400000 }; +} + +module.exports = { init, view, adopt, release, sweep, rescueSweep, rescueInfo, markContacted, adminView, waiting, hasOwnBuy, recordGift, CAP_OPEN, TTL_MS, WARN_DAYS, RESCUE_DAYS }; diff --git a/toolkit.js b/toolkit.js new file mode 100644 index 0000000..82c7406 --- /dev/null +++ b/toolkit.js @@ -0,0 +1,230 @@ +// Badge-gated promo toolkit + the AI Copy Engine (Marty, 2026-09-14). +// Tiers follow the achievement badges: free (no badge), Spark (payouts on), Surge (first qualifying buyer), +// Circuit (two), Nexus (five). The first AI tool unlocks at Surge. Each generation uses a monthly free +// allowance by tier, then costs ad credits from the member's earned pool (credits are advertising, 1 = 1 cent). +// The engine is the same OpenRouter model the chatbot uses; the LinkSpin facts, the compliance rules and +// the voice live here in version control and travel with every request. +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +let R = null; // { dataDir, ads, accounts, siteConfig, coach, messages, promos, videomaker } +const MODEL = process.env.OPENROUTER_MODEL || 'deepseek/deepseek-v4-flash:nitro'; +function init(refs) { R = refs; } +function key() { if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY.trim(); try { return fs.readFileSync(path.join(R.dataDir, 'openrouter.key'), 'utf8').trim(); } catch (e) { return ''; } } +const FILE = () => path.join(R.dataDir, 'toolkit-usage.json'); +function usage() { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { return {}; } } +function saveUsage(u) { try { fs.writeFileSync(FILE(), JSON.stringify(u)); } catch (e) {} } +const monthKey = () => new Date().toLocaleDateString('en-US', { timeZone: 'America/Chicago', year: 'numeric', month: '2-digit' }); + +// ---- the ladder (what each badge unlocks; 'live' items exist today, the rest are on the roadmap) ---- +const TIERS = [ + { key: 'free', name: 'Free', need: null, blurb: 'Everything you need to start sharing.', items: [ + { t: 'Invite link with six angle front doors', live: true }, { t: 'Social posts, text-a-friend, email swipes, banners', live: true }, { t: 'Banner wall and line banner', live: true }, { t: 'Objection handling and the shorts', live: true }, { t: 'Badge share pages', live: true }] }, + { key: 'spark', name: 'Spark', need: 'payouts', needText: 'switch on payouts', blurb: 'Your wallet is registered; the site can pay you.', items: [ + { t: 'Campaign templates: one-tap banner and text campaigns aimed at your link', live: true }, { t: 'Printable handout with your QR', live: true }] }, + { key: 'surge', name: 'Surge', need: 'firstBuyer', needText: 'your first qualifying buyer ($20+)', blurb: 'The first AI tool.', items: [ + { t: 'AI Copy Engine: posts, DMs, follow-ups, objection replies, emails and stories in the LinkSpin voice', live: true }, { t: 'Monthly free generations, then ad credits', live: true }] }, + { key: 'circuit', name: 'Circuit', need: 'level2', needText: 'two qualifying buyers', blurb: 'Creative and traffic.', items: [ + { t: 'Three times the free AI generations', live: true }, { t: 'Video Maker: the shorts and the explainer with your own end card and QR', live: true }, { t: 'Split tester for your join angles', live: true }] }, + { key: 'nexus', name: 'Nexus', need: 'level3', needText: 'five qualifying buyers', blurb: 'Leader tools.', items: [ + { t: 'Seven times the free AI generations', live: true }, { t: 'Leader Ops: team triage, one-click nudges, AI-drafted team broadcasts', live: true }, { t: 'Co-branded pages and team credit grants', live: true }, { t: 'Your own partner kit and promo code', live: true }] } +]; +function tierFor(badges) { + const has = k => badges.includes(k); + return has('level3') ? 'nexus' : has('level2') ? 'circuit' : has('firstBuyer') ? 'surge' : has('payouts') ? 'spark' : 'free'; +} +function allowanceFor(tier, sc) { + const n = k => Number(sc[k]); + return tier === 'nexus' ? (n('aiFreeNexus') || 150) : tier === 'circuit' ? (n('aiFreeCircuit') || 60) : tier === 'surge' ? (n('aiFreeSurge') || 20) : 0; +} + +// ---- the engine's standing orders ---- +const COMPLIANCE = [ + 'NEVER promise, guarantee, project or imply income, earnings, returns or profit.', + 'NEVER use hype: no "guaranteed", "risk-free", "passive income", "get rich", "financial freedom".', + 'LinkSpin SELLS ADVERTISING with a performance referral program. It is not an investment. Say so plainly when the length allows.', + 'The only money facts you may state: packages are $5, $20, $50, $100 and $250 of ad credits (1 credit = 1 cent of ad delivery); when someone in your line buys a package the contract pays the direct sponsor 50% in the same transaction, 20% to level 2, 10% to level 3, 20% to the platform; two qualifying $20+ buyers open level 2, five open level 3. Never invent other numbers or member results.', + 'Payments are POL on Polygon, wallet to wallet, on a public ledger anyone can check. Cryptocurrency carries real risk; results depend on effort.', + 'No em dashes and no double hyphens anywhere. No fake urgency, no fake scarcity, no invented testimonials.' +].join(' '); +const FACTS = 'LinkSpin (linkspin-test.saasy.top) is an advertising platform: members join free by email, earn ad credits daily by viewing a short set of ads (the claim grows with a streak), and spend credits on their own banner, text, login, solo, video, featured and verified-visit campaigns across the site and a partner network; views are timed on the server so a real person saw the ad. Ad packages from $5 buy more reach. Every package sold pays the sponsor instantly on-chain in the same transaction; there is no balance to withdraw because nothing is held. The daily routine members are taught: do the five-ad set and spend the claim, message one person in your line, check the holding tank, have one conversation with someone who already pays for traffic. Two qualifying buyers open the next level; five open the one after. Founder: Marty Bostick; brought to you by the Crypto Team Build Network.'; +const VOICE = 'Write like a straight-talking internet marketer who has been around long enough to hate hype: plain words, short sentences, honest about risk, warm and confident, never salesy. First person, as the member. Specific beats clever.'; +const KINDS = { + post: { label: 'Social post', shape: 'Write ONE social media post of 40-90 words. Strong first line. No hashtags. At most one emoji. End with a soft invitation to look, not a hard sell, and put the link on its own last line.' }, + dm: { label: 'Direct message', shape: 'Write ONE short direct message to a friend or contact, 30-60 words. Conversational, personal, zero pressure, sounds like a real text. Link on the last line.' }, + followup: { label: 'Follow-up', shape: 'Write ONE follow-up message (30-70 words) to someone who looked but has not joined or bought. No guilt, no pressure, no fake urgency. Give them one useful reason to look again. Link on the last line.' }, + objection: { label: 'Objection reply', shape: 'The brief is what the prospect said. Write ONE reply of 60-120 words: concede what is true first, give the honest answer, then invite them to check the ledger themselves. No link unless it helps.' }, + email: { label: 'Email', shape: 'Write ONE short email: first line "Subject: ..." then a blank line, then a 90-160 word plain-text body ending with a sign-off and the link on its own line.' }, + broadcast: { label: 'Team broadcast (leaders)', shape: 'Write ONE message from a team leader to their whole team: first line "Subject: ..." then a blank line, then an 80-150 word body. One clear focus for this week (from the brief, or pick: share your link daily, view your ads, message two people), one useful tip, warm and direct, no pressure, no invented numbers. Sign off as the member. No link needed.' }, + story: { label: 'Story post', shape: 'Write ONE first-person post of 80-140 words telling a small, believable personal story from the brief about advertising, building a line, or the daily routine. No invented numbers. Link on the last line.' } +}; +const ANGLES = { + plain: 'General: advertising that pays the sponsor instantly, on-chain.', + advertisers: 'Angle: for people who already pay for traffic somewhere; seven ad formats and every ad view timed on the server.', + earners: 'Angle: for people who like daily click-to-earn sites; free credits every day and a streak, spent on your own campaign.', + honest: 'Angle: the honest one; not passive income, it pays for work, every payout is public.', + receipt: 'Angle: the receipt test; before you join anything, check three random payouts on the public ledger yourself.', + builder: 'Angle: for people who run a downline builder or a team; their members join under them and get a welcome credit.' +}; + +function buildMessages(kind, brief, angle, member) { + const k = KINDS[kind] || KINDS.post; + const sys = 'You write promotional copy for a member of LinkSpin.\n\nWHAT IT IS: ' + FACTS + '\n\nVOICE: ' + VOICE + '\n\nCOMPLIANCE, the last word on everything: ' + COMPLIANCE + + '\n\nMEMBER: username ' + (member.username || 'member') + '. Their invite link is ' + member.link + ' and it is the ONLY link you may use. ' + (ANGLES[angle] || ANGLES.plain) + + '\n\nOUTPUT: ' + k.shape + ' Output the copy only: no preamble, no options, no quotes around it, no explanations.'; + return [{ role: 'system', content: sys }, { role: 'user', content: 'Brief: ' + String(brief || 'no brief; write something a real member would post today').slice(0, 800) }]; +} +function complete(messages) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ model: MODEL, max_tokens: 500, temperature: 0.8, messages }); + const req = https.request({ hostname: 'openrouter.ai', path: '/api/v1/chat/completions', method: 'POST', headers: { Authorization: 'Bearer ' + key(), 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 45000 }, res => { + let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d).choices[0].message.content.trim()); } catch (e) { reject(new Error('engine ' + res.statusCode + ': ' + d.slice(0, 120))); } }); + }); + req.on('error', reject); req.on('timeout', () => req.destroy(new Error('engine timeout'))); req.end(body); + }); +} +const scrub = t => String(t || '').replace(/\s*[—–]\s*|\s*--\s*/g, ', ').replace(/^["“]+|["”]+$/g, '').trim(); + +async function status(email) { + const acct = await R.accounts.byEmail(email); if (!acct) return { error: 'No such account.' }; + const badges = await R.ads.milestonesOf(email); + const tier = tierFor(badges); const sc = R.siteConfig(); + const u = usage()[email] || {}; const used = (u.months || {})[monthKey()] || 0; + const allowance = allowanceFor(tier, sc); const cost = Number(sc.aiCreditsPerGen) || 10; + let available = 0; try { const st = await R.ads.viewStatus(email); available = st.earnedAvailable != null ? st.earnedAvailable : (st.earned || 0); } catch (e) {} + const link = 'https://linkspin-test.saasy.top/join/' + (acct.username || acct.code); + const partner = tier === 'nexus' ? await partnerStatus(email) : null; + const handoutUrl = rank[tier] >= rank.spark && acct.username ? 'https://linkspin-test.saasy.top/handout/' + acct.username : null; + return { tier, badges, username: acct.username || null, partner, handoutUrl, videoMaker: !!(R.videomaker && R.videomaker.available()), unlocked: ['surge', 'circuit', 'nexus'].includes(tier), engine: !!key(), allowance, used, freeLeft: Math.max(0, allowance - used), cost, available, link, + kinds: Object.entries(KINDS).map(([k, v]) => ({ key: k, label: v.label })), angles: Object.keys(ANGLES), + tiers: TIERS.map(t => ({ key: t.key, name: t.name, needText: t.needText || '', blurb: t.blurb, items: t.items, reached: t.key === 'free' || badges.includes(t.need) })), + history: (u.history || []).slice(-12).reverse() }; +} +async function generate(email, kind, brief, angle) { + const st = await status(email); if (st.error) return st; + if (!st.unlocked) return { error: 'The AI Copy Engine unlocks at Surge: your first qualifying buyer of a $20 or larger package. Until then the ready-made posts and swipes are yours to use.' }; + if (!st.engine) return { error: 'The engine is not configured on this server yet.' }; + if (!KINDS[kind]) return { error: 'Pick what to write.' }; + const acct = await R.accounts.byEmail(email); + let charged = 0; + if (st.freeLeft <= 0) { + if (st.available < st.cost) return { error: 'Your free generations for this month are used up and this one costs ' + st.cost + ' credits; you have ' + st.available + ' available. Earn a few on the Earn tab and come back.' }; + if (!(await R.ads.spendEarned(email, st.cost))) return { error: 'Could not charge ' + st.cost + ' credits. Try again in a moment.' }; + charged = st.cost; + } + let text; + try { text = scrub(await complete(buildMessages(kind, brief, angle, { username: acct.username, link: st.link }))); } + catch (e) { if (charged) { try { await R.ads.addEarned(email, charged); } catch (x) {} } return { error: 'The engine did not answer. Nothing was charged. Try again.' }; } + const u = usage(); const me = u[email] = u[email] || { months: {}, history: [] }; + me.months[monthKey()] = (me.months[monthKey()] || 0) + 1; + me.history = (me.history || []).concat([{ ts: Date.now(), kind, angle: angle || 'plain', brief: String(brief || '').slice(0, 120), text: text.slice(0, 1500), charged }]).slice(-40); + saveUsage(u); + return { ok: true, text, kind, charged, freeLeft: Math.max(0, st.freeLeft - (charged ? 0 : 1)), cost: st.cost }; +} +async function adminUsage() { + const u = usage(); const m = monthKey(); const rows = []; + for (const [email, v] of Object.entries(u)) rows.push({ email, month: (v.months || {})[m] || 0, total: Object.values(v.months || {}).reduce((a, b) => a + b, 0), last: (v.history || []).slice(-1)[0] || null }); + return { month: m, rows: rows.sort((a, b) => b.month - a.month) }; +} +// ---- the rest of the ladder (Marty, 2026-09-14): Spark templates + handout, Circuit split tester + Video +// Maker, Nexus Leader Ops + team grants + a member-funded partner code ---- +const KIT = 'https://linkspin-test.saasy.top'; +async function tierOf(email) { return tierFor(await R.ads.milestonesOf(email)); } +const rank = { free: 0, spark: 1, surge: 2, circuit: 3, nexus: 4 }; +const atLeast = (tier, need) => rank[tier] >= rank[need]; + +// Spark: one-tap campaigns aimed at the member's link, funded from what they already hold +async function template(email, memberId, ids, kind, budget) { + const tier = await tierOf(email); if (!atLeast(tier, 'spark')) return { error: 'Campaign templates unlock at Spark: switch on payouts first.' }; + const acct = await R.accounts.byEmail(email); const link = KIT + '/join/' + (acct.username || acct.code); + const b = Math.max(50, Math.min(5000, Math.floor(budget) || 200)); + const T = { + banner: { type: 'banner', name: 'My invite: banner 300x250', targetUrl: link, imageUrl: KIT + '/banners/iap-300x250.png', size: '300x250', budget: b }, + leaderboard: { type: 'banner', name: 'My invite: banner 728x90', targetUrl: link, imageUrl: KIT + '/banners/iap-728x90.png', size: '728x90', budget: b }, + text: { type: 'text', name: 'My invite: text ad', targetUrl: link, title: 'Paid the second someone buys', body: 'An ad platform that pays sponsors in the same transaction, on-chain. Free to join, credits for showing up.', budget: b }, + text2: { type: 'text', name: 'My invite: text ad (free angle)', targetUrl: link + '?v=free', title: 'Run your first ad campaign for $0', body: 'Join free, view a few ads, earn credits, launch a campaign. Every payout is public on the ledger.', budget: b } + }[kind]; + if (!T) return { error: 'Pick a template.' }; + const r = await R.ads.createCampaign(email, memberId || 0, T, ids); + if (r.error) return r; + return { ok: true, campaign: r.campaign || r, name: T.name, budget: b }; +} + +// Spark: printable handout with the member's QR (public page so it can be shared or printed anywhere) +async function handout(username) { + const a = await R.accounts.byUsername(username); if (!a) return null; + if (!atLeast(await tierOf(a.email), 'spark')) return null; + const link = KIT + '/join/' + username; const qr = '/api/qr?d=' + encodeURIComponent(link); + const esc = t => String(t || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + return 'LinkSpin handout for @' + esc(username) + '' + + '' + + '
      ' + + '

      Two handouts per sheet. Cut along the gap.

      ' + + [1, 2].map(() => '
      LinkSpin
      LinkSpin advertise and earn, paid on-chain

      Your ads run. Your line pays you. Same transaction.

      Join free with your email. View a few ads a day and earn credits for your own campaign. When anyone you invite buys an ad package, the contract pays you instantly, wallet to wallet, on a public ledger.

      Scan the code or go to
      ' + esc(link) + '

      Your sponsor: @' + esc(username) + '

      QR code' + esc(link.replace('https://', '')) + '
      LinkSpin sells advertising with a performance referral program. Not an investment. No income is guaranteed; results depend on effort. Cryptocurrency carries risk.
      ').join('') + + '
      '; +} + +// Circuit: which join angle is pulling for this member +async function split(email) { + const tier = await tierOf(email); + const st = await R.coach.linkStats(email); + const acct = await R.accounts.byEmail(email); const base = KIT + '/join/' + (acct.username || acct.code); + const rows = (st.angles || []).map(a => ({ angle: a.angle, link: base + (a.angle && a.angle !== 'plain' ? '?v=' + a.angle : ''), views: a.views || 0, views30: a.views30 || 0, joins: a.joins || 0, buyers: a.buyers || 0, rate: a.views ? Math.round(1000 * (a.joins || 0) / a.views) / 10 : 0 })); + const withData = rows.filter(r => r.views >= 10); const best = withData.length ? withData.slice().sort((x, y) => y.rate - x.rate || y.joins - x.joins)[0] : null; + return { unlocked: atLeast(tier, 'circuit'), rows, best: best ? best.angle : null, sources: st.sources || [] }; +} +async function videos(email) { + const tier = await tierOf(email); const acct = await R.accounts.byEmail(email); + return { unlocked: atLeast(tier, 'circuit'), available: R.videomaker.available(), username: acct.username || null, list: acct.username ? R.videomaker.list(acct.username) : [] }; +} +async function makeVideo(email, slug) { + if (!atLeast(await tierOf(email), 'circuit')) return { error: 'The Video Maker unlocks at Circuit: two qualifying buyers.' }; + const acct = await R.accounts.byEmail(email); if (!acct.username) return { error: 'Pick a username first; it goes on the end card.' }; + return R.videomaker.enqueue(email, acct.username, slug); +} + +// Nexus: Leader Ops (three levels, stalled flags, one-click nudge), grants, partner code +async function team(email) { + const tier = await tierOf(email); if (!atLeast(tier, 'nexus')) return { unlocked: false }; + const levels = await R.accounts.downline(email, 3); const now = Date.now(); const out = []; + for (const L of levels) for (const m of L.members.slice(0, 80)) { const c = await R.coach.describe(m, now); out.push({ level: L.level, email: m.email, name: m.username ? '@' + m.username : m.email.replace(/@.*/, '') + '@', rung: c.rung, label: c.label, next: c.next, say: c.say, quietDays: c.quietDays, stalled: c.stalled, joined: m.created, lastSeen: m.lastSeen || 0 }); } + const stalled = out.filter(x => x.stalled).sort((a, b) => b.quietDays - a.quietDays); + return { unlocked: true, total: out.length, byLevel: [1, 2, 3].map(l => out.filter(x => x.level === l).length), stalled: stalled.slice(0, 40), recent: out.filter(x => now - x.joined < 7 * 86400000).length, members: out.slice(0, 200) }; +} +async function nudge(fromEmail, fromMember, toEmail, text) { + if (!atLeast(await tierOf(fromEmail), 'nexus')) return { error: 'Leader Ops unlocks at Nexus.' }; + const to = String(toEmail || '').toLowerCase(); if (!(await R.accounts.isDownlineOf(fromEmail, to, 3))) return { error: 'That member is not in your line.' }; + const body = String(text || '').trim().slice(0, 600); if (body.length < 5) return { error: 'Write the message first.' }; + await R.messages.sendChat(fromMember || 0, fromEmail, to, body); return { ok: true }; +} +async function grant(fromEmail, toEmail, credits) { + if (!atLeast(await tierOf(fromEmail), 'nexus')) return { error: 'Team grants unlock at Nexus.' }; + const to = String(toEmail || '').toLowerCase(); const n = Math.floor(credits); + if (!(n >= 10 && n <= 2000)) return { error: 'Grant between 10 and 2,000 credits at a time.' }; + if (!(await R.accounts.isDownlineOf(fromEmail, to, 3))) return { error: 'That member is not in your line.' }; + let avail = 0; try { const st = await R.ads.viewStatus(fromEmail); avail = st.earnedAvailable != null ? st.earnedAvailable : (st.earned || 0); } catch (e) {} + if (avail < n) return { error: 'You have ' + avail + ' credits available to give; free some up or earn more first.' }; + if (!(await R.ads.spendEarned(fromEmail, n))) return { error: 'Could not move the credits. Try again.' }; + await R.ads.addEarned(to, n); + const a = await R.accounts.byEmail(fromEmail), b = await R.accounts.byEmail(to); + const fromName = a.username ? '@' + a.username : 'your sponsor', toName = b.username ? '@' + b.username : to.replace(/@.*/, '') + '@'; + try { await R.messages.sendChat(a.memberId || 0, fromEmail, to, 'I just moved ' + n + ' ad credits to your account. Spend them on a campaign pointed at your invite link: Campaigns > New campaign.'); } catch (e) {} + return { ok: true, credits: n, fromName, toName }; +} +async function partnerCode(email, code, credits) { + if (!atLeast(await tierOf(email), 'nexus')) return { error: 'Your own partner code unlocks at Nexus.' }; + const mine = await R.promos.byFunder(email); + const n = Math.floor(credits); if (!(n >= 50 && n <= 500)) return { error: 'Set the welcome between 50 and 500 credits; they come out of your earned pool each time someone redeems.' }; + if (mine.length && mine[0].code !== R.promos.norm(code)) return { error: 'You already have a code: ' + mine[0].code + '. Change its amount here or leave it.' }; + const r = await R.promos.create({ code, credits: n, funder: email, partner: 'member: ' + email, note: 'member-funded partner code', maxUses: 0, expires: null, active: true }); + if (r.error) return r; + const acct = await R.accounts.byEmail(email); + return { ok: true, code: r.code.code, credits: n, link: KIT + '/join/' + (acct.username || acct.code) + '?promo=' + r.code.code, kit: KIT + '/partners?ref=' + (acct.username || acct.code) + '&promo=' + r.code.code }; +} +async function partnerStatus(email) { + const mine = await R.promos.byFunder(email); const acct = await R.accounts.byEmail(email); + if (!mine.length) return null; const c = mine[0]; + return { code: c.code, credits: c.credits, uses: (await R.promos.adminView()).codes.find(x => x.code === c.code)?.uses || 0, link: KIT + '/join/' + (acct.username || acct.code) + '?promo=' + c.code, kit: KIT + '/partners?ref=' + (acct.username || acct.code) + '&promo=' + c.code }; +} + +module.exports = { init, status, generate, adminUsage, TIERS, KINDS, template, handout, split, videos, makeVideo, team, nudge, grant, partnerCode, partnerStatus }; diff --git a/tools/gen-social-banners.cjs b/tools/gen-social-banners.cjs new file mode 100644 index 0000000..86b11fe --- /dev/null +++ b/tools/gen-social-banners.cjs @@ -0,0 +1,67 @@ +// Renders the square / story / Telegram social banners for the promo kit. +// Run from the site dir with the qa-tester playwright: +// node tools/gen-social-banners.cjs +// Output: public/banners/iap-x.png (exact pixel sizes, DSF 1) +const path = require('path'); +const fs = require('fs'); +const { chromium } = require('D:/Projects/MarketingAgent/qa-tester/node_modules/playwright'); + +const OUT = path.join(__dirname, '..', 'public', 'banners'); +const logo = 'data:image/png;base64,' + fs.readFileSync(path.join(__dirname, '..', 'public', 'logo.png')).toString('base64'); + +const SIZES = [ + { w: 1080, h: 1080, kind: 'square' }, + { w: 1080, h: 1920, kind: 'story' }, + { w: 1280, h: 720, kind: 'telegram' } +]; + +function html({ w, h, kind }) { + const story = kind === 'story', tg = kind === 'telegram'; + const pad = Math.round(w * 0.075); + const h1 = story ? 92 : tg ? 66 : 84; + const lead = story ? 36 : tg ? 27 : 32; + const logoH = story ? 120 : tg ? 84 : 104; + const cta = tg ? 'Tap the link below' : 'Join free'; + return `
      +
      +
      +
      + Polygon · same-transaction payouts +

      Advertise & earn.
      Paid on-chain, instantly.

      +

      Every ad package splits to real wallets the second it sells. Public ledger. Nothing to claim.

      +
      +
      ${cta} →instantadpay.com
      +
      `; +} + +(async () => { + const browser = await chromium.launch(); + for (const s of SIZES) { + const page = await browser.newPage({ viewport: { width: s.w, height: s.h }, deviceScaleFactor: 1 }); + await page.setContent(html(s), { waitUntil: 'load' }); + await page.evaluate(() => document.fonts.ready); + await page.waitForFunction(() => { const i = document.querySelector('img.logo'); return i && i.complete && i.naturalWidth > 0; }); + await page.waitForTimeout(400); + const file = path.join(OUT, `iap-${s.w}x${s.h}.png`); + await page.screenshot({ path: file, clip: { x: 0, y: 0, width: s.w, height: s.h } }); + console.log('wrote', file); + await page.close(); + } + await browser.close(); +})(); diff --git a/traffic.js b/traffic.js new file mode 100644 index 0000000..c3a3b5f --- /dev/null +++ b/traffic.js @@ -0,0 +1,60 @@ +// Page-hit log for the admin Traffic tab (Marty, 2026-09-12): which referring domains send +// visitors to the public pages, per day and per landing page. Counters are buffered in memory +// and flushed every 30 s: MySQL table page_hits (day, host, path, n) when the DB is on, else +// DATA_DIR/traffic.json. Obvious crawlers are skipped. Our own pages as referrer = 'direct'. +const fs = require('fs'); +const path = require('path'); +const db = require('./db'); +let DATA_DIR = null, buf = {}, jsonStore = null, timer = null; +const BOT = /bot|crawl|spider|slurp|facebookexternalhit|preview|monitor|curl\/|wget|python-requests|headless/i; + +function init(opts) { + DATA_DIR = opts.dataDir; + if (!timer) { timer = setInterval(() => flush().catch(() => {}), 30 * 1000); timer.unref(); } +} +function host(referer) { + try { const h = new URL(String(referer || '')).hostname.replace(/^www\./, '').toLowerCase(); return (!h || /linkspin\.com$/.test(h)) ? 'direct' : h.slice(0, 80); } catch (e) { return 'direct'; } +} +function family(p) { + if (p === '/' || p === '') return 'home'; + if (p.startsWith('/join/')) return 'join'; + if (p.startsWith('/from/')) return p.slice(1, 40); // from/faucetwave, from/tieroneads + if (p.startsWith('/wall/')) return 'wall'; + return p.replace(/^\//, '').slice(0, 40) || 'home'; // ledger, contract, shorts, plays ... +} +const day = ts => new Date(ts).toISOString().slice(0, 10); +function hit(p, referer, ua) { + if (BOT.test(String(ua || ''))) return; + const k = day(Date.now()) + '|' + host(referer) + '|' + family(p); + buf[k] = (buf[k] || 0) + 1; +} +function loadJson() { + if (jsonStore) return jsonStore; + try { jsonStore = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'traffic.json'), 'utf8')); } catch (e) { jsonStore = {}; } + return jsonStore; +} +async function flush() { + const pending = buf; buf = {}; + const keys = Object.keys(pending); if (!keys.length) return; + if (db.enabled()) { + for (const k of keys) { const [d, h, f] = k.split('|'); await db.q('INSERT INTO page_hits (day,host,path,n) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE n=n+VALUES(n)', [d, h, f, pending[k]]); } + return; + } + const J = loadJson(); + for (const k of keys) J[k] = (J[k] || 0) + pending[k]; + // keep the JSON store bounded: drop days older than 400 + const cutoff = day(Date.now() - 400 * 86400000); + for (const k of Object.keys(J)) if (k.slice(0, 10) < cutoff) delete J[k]; + fs.writeFileSync(path.join(DATA_DIR, 'traffic.json'), JSON.stringify(J)); +} +// rows since a day (inclusive): [{day, host, path, n}] +async function rows(sinceDay) { + await flush(); + if (db.enabled()) { + const r = await db.q('SELECT day, host, path, n FROM page_hits WHERE day>=?', [sinceDay]); + return r.map(x => ({ day: x.day, host: x.host, path: x.path, n: Number(x.n) })); + } + const J = loadJson(); + return Object.keys(J).filter(k => k.slice(0, 10) >= sinceDay).map(k => { const [d, h, p] = k.split('|'); return { day: d, host: h, path: p, n: J[k] }; }); +} +module.exports = { init, hit, flush, rows, host, family }; diff --git a/updates.js b/updates.js new file mode 100644 index 0000000..f6424de --- /dev/null +++ b/updates.js @@ -0,0 +1,96 @@ +// Member update emails (Marty, 2026-09-14): the admin picks release notes, writes a short intro, chooses an +// audience, previews, sends a test to themselves, then sends. Plain text through the site mailer (SendGrid), +// one recipient at a time with a small gap, opt-outs honoured (the same unsubscribe link the drip uses). +// Log: DATA_DIR/updates-log.json. Nothing here sends on its own: every send is a click in Admin > Releases. +const fs = require('fs'); +const path = require('path'); +let R = null; // { dataDir, accounts, releases, mailer, drip, sendy, adminEmail } +const SITE = 'https://linkspin-test.saasy.top'; +const FILE = () => path.join(R.dataDir, 'updates-log.json'); +function log() { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { return { sends: [] }; } } +function saveLog(l) { try { fs.writeFileSync(FILE(), JSON.stringify(l)); } catch (e) {} } +function init(refs) { R = refs; } + +const AUDIENCES = { optin: 'Newsletter opt-ins (ticked the box at sign-up)', optin30: 'Newsletter opt-ins active in the last 30 days', all: 'Every member, including those who declined the newsletter' }; +// the sign-up checkbox subscribes the member to the Sendy newsletter list, so Sendy is the record of who opted in +const optinCache = new Map(); // email -> { t, v } +const KNOWN = ['Subscribed', 'Unsubscribed', 'Unconfirmed', 'Bounced', 'Soft bounced', 'Complained', 'Email does not exist in list']; +let unknownOptins = 0; // answers Sendy did not give (timeouts, rate limits): counted, never treated as a decline for long +async function optedIn(email) { + const c = optinCache.get(email); if (c && Date.now() - c.t < (c.known ? 6 * 3600000 : 60000)) return c.v; + let st = ''; for (let i = 0; i < 3 && !KNOWN.includes(st); i++) { if (i) await new Promise(r => setTimeout(r, 800 * i)); st = R.sendy ? await R.sendy.status(email) : ''; } + await new Promise(r => setTimeout(r, 250)); // Sendy's status endpoint throttles bursts (2026-09-14: an unpaced pass under-counted 126 opt-ins as 92) + const known = KNOWN.includes(st); if (!known) unknownOptins += 1; + const v = st === 'Subscribed'; optinCache.set(email, { t: Date.now(), v, known }); return v; +} +async function recipients(kind) { + const all = await R.accounts.listAll(20000); + const now = Date.now(); const days = kind === 'optin30' ? 30 : 0; const needOptin = kind !== 'all'; + const out = []; + for (const a of all) { + if (!a.email || /@(demo|example)\./i.test(a.email)) continue; + if (days && now - Math.max(a.lastSeen || 0, a.created || 0) > days * 86400000) continue; + if (needOptin && !(await optedIn(a.email))) continue; + out.push(a); + } + return out; +} +async function counts() { const o = {}; for (const k of Object.keys(AUDIENCES)) o[k] = (await recipients(k)).length; return o; } + +function compose({ subject, intro, noteIds, closing }, acct) { + const notes = R.releases.notes().filter(n => noteIds.includes(n.id)); + const name = acct && acct.username ? '@' + acct.username : 'there'; + const parts = ['Hi ' + name + ',']; + if (intro && intro.trim()) parts.push(intro.trim()); + for (const n of notes) parts.push(n.title.toUpperCase() + (n.date ? ' (' + n.date + ')' : '') + '\n' + String(n.body || '').trim()); + if (closing && closing.trim()) parts.push(closing.trim()); // sign-off AFTER the notes (Marty, 2026-09-14) + parts.push('Every release note and what is being built next: ' + SITE + '/whats-new'); + parts.push('Sign in: ' + SITE + '/my'); + parts.push('LinkSpin\nAdvertise and earn instantly. Locked in code, not promises.\nNo income is guaranteed; results depend on your effort. Cryptocurrency carries risk.'); + if (acct && acct.email) parts.push('Stop these update emails: ' + R.drip.unsubUrl(acct.email)); + return { subject: String(subject || '').trim().slice(0, 150) || 'What is new on LinkSpin', text: parts.join('\n\n') }; +} + +let running = null; +async function send(input, opts) { + const noteIds = (input.noteIds || []).map(String); if (!noteIds.length) return { error: 'Pick at least one release note.' }; + if (!R.mailer.hasKey()) return { error: 'The mailer is not configured on this server.' }; + if (opts && opts.test) { + const to = R.adminEmail; if (!to) return { error: 'No admin email configured.' }; + const acct = (await R.accounts.byEmail(to)) || { email: to, username: null }; + const m = compose(input, acct); + await R.mailer.send(to, '[TEST] ' + m.subject, m.text); + return { ok: true, test: true, to }; + } + if (running) return { error: 'A send is already running (' + running.sent + ' of ' + running.total + '). Wait for it to finish.' }; + const kind = AUDIENCES[input.audience] ? input.audience : 'optin'; + unknownOptins = 0; + let list = await recipients(kind); + if (Array.isArray(input.to) && input.to.length) { const want = new Set(input.to.map(e => String(e).toLowerCase())); const all = await R.accounts.listAll(20000); list = all.filter(a => a.email && want.has(String(a.email).toLowerCase())); } // explicit list (repairs) + if (unknownOptins && !(input.to && input.to.length)) console.log('updates: Sendy gave no answer for', unknownOptins, 'members; they were left out of this send'); + if (!list.length) return { error: 'Nobody in that audience.' }; + const rec = { id: Date.now().toString(36), ts: Date.now(), subject: compose(input, null).subject, noteIds, audience: kind, total: list.length, sent: 0, skipped: 0, failed: 0, status: 'running', unknown: unknownOptins, recipients: list.map(a => a.email) }; + const l = log(); l.sends.unshift(rec); l.sends = l.sends.slice(0, 50); saveLog(l); running = rec; + (async () => { + for (const acct of list) { + try { + if (R.drip.isUnsubscribed && await R.drip.isUnsubscribed(acct.email)) { rec.skipped += 1; continue; } + const m = compose(input, acct); + await R.mailer.send(acct.email, m.subject, m.text); rec.sent += 1; (rec.delivered = rec.delivered || []).push(acct.email); + } catch (e) { rec.failed += 1; console.error('updates send', acct.email, e.message); } + if ((rec.sent + rec.failed + rec.skipped) % 10 === 0) { const l2 = log(); const k = l2.sends.find(x => x.id === rec.id); if (k) Object.assign(k, rec); saveLog(l2); } + await new Promise(r => setTimeout(r, 150)); + } + rec.status = 'done'; rec.doneAt = Date.now(); + const l3 = log(); const k = l3.sends.find(x => x.id === rec.id); if (k) Object.assign(k, rec); saveLog(l3); running = null; + console.log('updates sent', rec.subject, rec.sent, 'of', rec.total, 'skipped', rec.skipped, 'failed', rec.failed); + })(); + return { ok: true, id: rec.id, total: rec.total }; +} +// one saved draft (subject, intro, noteIds, audience): the admin card loads it, Save draft writes it +const DRAFT = () => path.join(R.dataDir, 'updates-draft.json'); +function draft() { try { return JSON.parse(fs.readFileSync(DRAFT(), 'utf8')); } catch (e) { return null; } } +function saveDraft(d) { const v = { subject: String(d.subject || '').slice(0, 150), intro: String(d.intro || '').slice(0, 8000), closing: String(d.closing || '').slice(0, 2000), noteIds: (d.noteIds || []).map(String).slice(0, 40), audience: AUDIENCES[d.audience] ? d.audience : 'optin', savedAt: Date.now() }; try { fs.writeFileSync(DRAFT(), JSON.stringify(v)); } catch (e) {} return v; } +function status() { const l = log(); if (running) { const k = l.sends.find(x => x.id === running.id); if (k) Object.assign(k, running); } return { sends: l.sends.slice(0, 12), running: !!running }; } +function lastSentAt() { const l = log(); const d = l.sends.find(x => x.status === 'done'); return d ? d.ts : 0; } +module.exports = { init, AUDIENCES, counts, compose, send, status, lastSentAt, draft, saveDraft }; diff --git a/vendor/secp256k1.js b/vendor/secp256k1.js new file mode 100644 index 0000000..33a0843 --- /dev/null +++ b/vendor/secp256k1.js @@ -0,0 +1,1230 @@ +"use strict"; +/*! noble-secp256k1 - MIT License (c) 2019 Paul Miller (paulmillr.com) */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.utils = exports.schnorr = exports.verify = exports.signSync = exports.sign = exports.getSharedSecret = exports.recoverPublicKey = exports.getPublicKey = exports.Signature = exports.Point = exports.CURVE = void 0; +const nodeCrypto = require("crypto"); +const _0n = BigInt(0); +const _1n = BigInt(1); +const _2n = BigInt(2); +const _3n = BigInt(3); +const _8n = BigInt(8); +const CURVE = Object.freeze({ + a: _0n, + b: BigInt(7), + P: BigInt('0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f'), + n: BigInt('0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141'), + h: _1n, + Gx: BigInt('55066263022277343669578718895168534326250603453777594175500187360389116729240'), + Gy: BigInt('32670510020758816978083085130507043184471273380659243275938904335757337482424'), + beta: BigInt('0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee'), +}); +exports.CURVE = CURVE; +const divNearest = (a, b) => (a + b / _2n) / b; +const endo = { + beta: BigInt('0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee'), + splitScalar(k) { + const { n } = CURVE; + const a1 = BigInt('0x3086d221a7d46bcde86c90e49284eb15'); + const b1 = -_1n * BigInt('0xe4437ed6010e88286f547fa90abfe4c3'); + const a2 = BigInt('0x114ca50f7a8e2f3f657c1108d9d44cfd8'); + const b2 = a1; + const POW_2_128 = BigInt('0x100000000000000000000000000000000'); + const c1 = divNearest(b2 * k, n); + const c2 = divNearest(-b1 * k, n); + let k1 = mod(k - c1 * a1 - c2 * a2, n); + let k2 = mod(-c1 * b1 - c2 * b2, n); + const k1neg = k1 > POW_2_128; + const k2neg = k2 > POW_2_128; + if (k1neg) + k1 = n - k1; + if (k2neg) + k2 = n - k2; + if (k1 > POW_2_128 || k2 > POW_2_128) { + throw new Error('splitScalarEndo: Endomorphism failed, k=' + k); + } + return { k1neg, k1, k2neg, k2 }; + }, +}; +const fieldLen = 32; +const groupLen = 32; +const hashLen = 32; +const compressedLen = fieldLen + 1; +const uncompressedLen = 2 * fieldLen + 1; +function weierstrass(x) { + const { a, b } = CURVE; + const x2 = mod(x * x); + const x3 = mod(x2 * x); + return mod(x3 + a * x + b); +} +const USE_ENDOMORPHISM = CURVE.a === _0n; +class ShaError extends Error { + constructor(message) { + super(message); + } +} +function assertJacPoint(other) { + if (!(other instanceof JacobianPoint)) + throw new TypeError('JacobianPoint expected'); +} +class JacobianPoint { + constructor(x, y, z) { + this.x = x; + this.y = y; + this.z = z; + } + static fromAffine(p) { + if (!(p instanceof Point)) { + throw new TypeError('JacobianPoint#fromAffine: expected Point'); + } + if (p.equals(Point.ZERO)) + return JacobianPoint.ZERO; + return new JacobianPoint(p.x, p.y, _1n); + } + static toAffineBatch(points) { + const toInv = invertBatch(points.map((p) => p.z)); + return points.map((p, i) => p.toAffine(toInv[i])); + } + static normalizeZ(points) { + return JacobianPoint.toAffineBatch(points).map(JacobianPoint.fromAffine); + } + equals(other) { + assertJacPoint(other); + const { x: X1, y: Y1, z: Z1 } = this; + const { x: X2, y: Y2, z: Z2 } = other; + const Z1Z1 = mod(Z1 * Z1); + const Z2Z2 = mod(Z2 * Z2); + const U1 = mod(X1 * Z2Z2); + const U2 = mod(X2 * Z1Z1); + const S1 = mod(mod(Y1 * Z2) * Z2Z2); + const S2 = mod(mod(Y2 * Z1) * Z1Z1); + return U1 === U2 && S1 === S2; + } + negate() { + return new JacobianPoint(this.x, mod(-this.y), this.z); + } + double() { + const { x: X1, y: Y1, z: Z1 } = this; + const A = mod(X1 * X1); + const B = mod(Y1 * Y1); + const C = mod(B * B); + const x1b = X1 + B; + const D = mod(_2n * (mod(x1b * x1b) - A - C)); + const E = mod(_3n * A); + const F = mod(E * E); + const X3 = mod(F - _2n * D); + const Y3 = mod(E * (D - X3) - _8n * C); + const Z3 = mod(_2n * Y1 * Z1); + return new JacobianPoint(X3, Y3, Z3); + } + add(other) { + assertJacPoint(other); + const { x: X1, y: Y1, z: Z1 } = this; + const { x: X2, y: Y2, z: Z2 } = other; + if (X2 === _0n || Y2 === _0n) + return this; + if (X1 === _0n || Y1 === _0n) + return other; + const Z1Z1 = mod(Z1 * Z1); + const Z2Z2 = mod(Z2 * Z2); + const U1 = mod(X1 * Z2Z2); + const U2 = mod(X2 * Z1Z1); + const S1 = mod(mod(Y1 * Z2) * Z2Z2); + const S2 = mod(mod(Y2 * Z1) * Z1Z1); + const H = mod(U2 - U1); + const r = mod(S2 - S1); + if (H === _0n) { + if (r === _0n) { + return this.double(); + } + else { + return JacobianPoint.ZERO; + } + } + const HH = mod(H * H); + const HHH = mod(H * HH); + const V = mod(U1 * HH); + const X3 = mod(r * r - HHH - _2n * V); + const Y3 = mod(r * (V - X3) - S1 * HHH); + const Z3 = mod(Z1 * Z2 * H); + return new JacobianPoint(X3, Y3, Z3); + } + subtract(other) { + return this.add(other.negate()); + } + multiplyUnsafe(scalar) { + const P0 = JacobianPoint.ZERO; + if (typeof scalar === 'bigint' && scalar === _0n) + return P0; + let n = normalizeScalar(scalar); + if (n === _1n) + return this; + if (!USE_ENDOMORPHISM) { + let p = P0; + let d = this; + while (n > _0n) { + if (n & _1n) + p = p.add(d); + d = d.double(); + n >>= _1n; + } + return p; + } + let { k1neg, k1, k2neg, k2 } = endo.splitScalar(n); + let k1p = P0; + let k2p = P0; + let d = this; + while (k1 > _0n || k2 > _0n) { + if (k1 & _1n) + k1p = k1p.add(d); + if (k2 & _1n) + k2p = k2p.add(d); + d = d.double(); + k1 >>= _1n; + k2 >>= _1n; + } + if (k1neg) + k1p = k1p.negate(); + if (k2neg) + k2p = k2p.negate(); + k2p = new JacobianPoint(mod(k2p.x * endo.beta), k2p.y, k2p.z); + return k1p.add(k2p); + } + precomputeWindow(W) { + const windows = USE_ENDOMORPHISM ? 128 / W + 1 : 256 / W + 1; + const points = []; + let p = this; + let base = p; + for (let window = 0; window < windows; window++) { + base = p; + points.push(base); + for (let i = 1; i < 2 ** (W - 1); i++) { + base = base.add(p); + points.push(base); + } + p = base.double(); + } + return points; + } + wNAF(n, affinePoint) { + if (!affinePoint && this.equals(JacobianPoint.BASE)) + affinePoint = Point.BASE; + const W = (affinePoint && affinePoint._WINDOW_SIZE) || 1; + if (256 % W) { + throw new Error('Point#wNAF: Invalid precomputation window, must be power of 2'); + } + let precomputes = affinePoint && pointPrecomputes.get(affinePoint); + if (!precomputes) { + precomputes = this.precomputeWindow(W); + if (affinePoint && W !== 1) { + precomputes = JacobianPoint.normalizeZ(precomputes); + pointPrecomputes.set(affinePoint, precomputes); + } + } + let p = JacobianPoint.ZERO; + let f = JacobianPoint.BASE; + const windows = 1 + (USE_ENDOMORPHISM ? 128 / W : 256 / W); + const windowSize = 2 ** (W - 1); + const mask = BigInt(2 ** W - 1); + const maxNumber = 2 ** W; + const shiftBy = BigInt(W); + for (let window = 0; window < windows; window++) { + const offset = window * windowSize; + let wbits = Number(n & mask); + n >>= shiftBy; + if (wbits > windowSize) { + wbits -= maxNumber; + n += _1n; + } + const offset1 = offset; + const offset2 = offset + Math.abs(wbits) - 1; + const cond1 = window % 2 !== 0; + const cond2 = wbits < 0; + if (wbits === 0) { + f = f.add(constTimeNegate(cond1, precomputes[offset1])); + } + else { + p = p.add(constTimeNegate(cond2, precomputes[offset2])); + } + } + return { p, f }; + } + multiply(scalar, affinePoint) { + let n = normalizeScalar(scalar); + let point; + let fake; + if (USE_ENDOMORPHISM) { + const { k1neg, k1, k2neg, k2 } = endo.splitScalar(n); + let { p: k1p, f: f1p } = this.wNAF(k1, affinePoint); + let { p: k2p, f: f2p } = this.wNAF(k2, affinePoint); + k1p = constTimeNegate(k1neg, k1p); + k2p = constTimeNegate(k2neg, k2p); + k2p = new JacobianPoint(mod(k2p.x * endo.beta), k2p.y, k2p.z); + point = k1p.add(k2p); + fake = f1p.add(f2p); + } + else { + const { p, f } = this.wNAF(n, affinePoint); + point = p; + fake = f; + } + return JacobianPoint.normalizeZ([point, fake])[0]; + } + toAffine(invZ) { + const { x, y, z } = this; + const is0 = this.equals(JacobianPoint.ZERO); + if (invZ == null) + invZ = is0 ? _8n : invert(z); + const iz1 = invZ; + const iz2 = mod(iz1 * iz1); + const iz3 = mod(iz2 * iz1); + const ax = mod(x * iz2); + const ay = mod(y * iz3); + const zz = mod(z * iz1); + if (is0) + return Point.ZERO; + if (zz !== _1n) + throw new Error('invZ was invalid'); + return new Point(ax, ay); + } +} +JacobianPoint.BASE = new JacobianPoint(CURVE.Gx, CURVE.Gy, _1n); +JacobianPoint.ZERO = new JacobianPoint(_0n, _1n, _0n); +function constTimeNegate(condition, item) { + const neg = item.negate(); + return condition ? neg : item; +} +const pointPrecomputes = new WeakMap(); +class Point { + constructor(x, y) { + this.x = x; + this.y = y; + } + _setWindowSize(windowSize) { + this._WINDOW_SIZE = windowSize; + pointPrecomputes.delete(this); + } + hasEvenY() { + return this.y % _2n === _0n; + } + static fromCompressedHex(bytes) { + const isShort = bytes.length === 32; + const x = bytesToNumber(isShort ? bytes : bytes.subarray(1)); + if (!isValidFieldElement(x)) + throw new Error('Point is not on curve'); + const y2 = weierstrass(x); + let y = sqrtMod(y2); + const isYOdd = (y & _1n) === _1n; + if (isShort) { + if (isYOdd) + y = mod(-y); + } + else { + const isFirstByteOdd = (bytes[0] & 1) === 1; + if (isFirstByteOdd !== isYOdd) + y = mod(-y); + } + const point = new Point(x, y); + point.assertValidity(); + return point; + } + static fromUncompressedHex(bytes) { + const x = bytesToNumber(bytes.subarray(1, fieldLen + 1)); + const y = bytesToNumber(bytes.subarray(fieldLen + 1, fieldLen * 2 + 1)); + const point = new Point(x, y); + point.assertValidity(); + return point; + } + static fromHex(hex) { + const bytes = ensureBytes(hex); + const len = bytes.length; + const header = bytes[0]; + if (len === fieldLen) + return this.fromCompressedHex(bytes); + if (len === compressedLen && (header === 0x02 || header === 0x03)) { + return this.fromCompressedHex(bytes); + } + if (len === uncompressedLen && header === 0x04) + return this.fromUncompressedHex(bytes); + throw new Error(`Point.fromHex: received invalid point. Expected 32-${compressedLen} compressed bytes or ${uncompressedLen} uncompressed bytes, not ${len}`); + } + static fromPrivateKey(privateKey) { + return Point.BASE.multiply(normalizePrivateKey(privateKey)); + } + static fromSignature(msgHash, signature, recovery) { + const { r, s } = normalizeSignature(signature); + if (![0, 1, 2, 3].includes(recovery)) + throw new Error('Cannot recover: invalid recovery bit'); + const h = truncateHash(ensureBytes(msgHash)); + const { n } = CURVE; + const radj = recovery === 2 || recovery === 3 ? r + n : r; + const rinv = invert(radj, n); + const u1 = mod(-h * rinv, n); + const u2 = mod(s * rinv, n); + const prefix = recovery & 1 ? '03' : '02'; + const R = Point.fromHex(prefix + numTo32bStr(radj)); + const Q = Point.BASE.multiplyAndAddUnsafe(R, u1, u2); + if (!Q) + throw new Error('Cannot recover signature: point at infinify'); + Q.assertValidity(); + return Q; + } + toRawBytes(isCompressed = false) { + return hexToBytes(this.toHex(isCompressed)); + } + toHex(isCompressed = false) { + const x = numTo32bStr(this.x); + if (isCompressed) { + const prefix = this.hasEvenY() ? '02' : '03'; + return `${prefix}${x}`; + } + else { + return `04${x}${numTo32bStr(this.y)}`; + } + } + toHexX() { + return this.toHex(true).slice(2); + } + toRawX() { + return this.toRawBytes(true).slice(1); + } + assertValidity() { + const msg = 'Point is not on elliptic curve'; + const { x, y } = this; + if (!isValidFieldElement(x) || !isValidFieldElement(y)) + throw new Error(msg); + const left = mod(y * y); + const right = weierstrass(x); + if (mod(left - right) !== _0n) + throw new Error(msg); + } + equals(other) { + return this.x === other.x && this.y === other.y; + } + negate() { + return new Point(this.x, mod(-this.y)); + } + double() { + return JacobianPoint.fromAffine(this).double().toAffine(); + } + add(other) { + return JacobianPoint.fromAffine(this).add(JacobianPoint.fromAffine(other)).toAffine(); + } + subtract(other) { + return this.add(other.negate()); + } + multiply(scalar) { + return JacobianPoint.fromAffine(this).multiply(scalar, this).toAffine(); + } + multiplyAndAddUnsafe(Q, a, b) { + const P = JacobianPoint.fromAffine(this); + const aP = a === _0n || a === _1n || this !== Point.BASE ? P.multiplyUnsafe(a) : P.multiply(a); + const bQ = JacobianPoint.fromAffine(Q).multiplyUnsafe(b); + const sum = aP.add(bQ); + return sum.equals(JacobianPoint.ZERO) ? undefined : sum.toAffine(); + } +} +exports.Point = Point; +Point.BASE = new Point(CURVE.Gx, CURVE.Gy); +Point.ZERO = new Point(_0n, _0n); +function sliceDER(s) { + return Number.parseInt(s[0], 16) >= 8 ? '00' + s : s; +} +function parseDERInt(data) { + if (data.length < 2 || data[0] !== 0x02) { + throw new Error(`Invalid signature integer tag: ${bytesToHex(data)}`); + } + const len = data[1]; + const res = data.subarray(2, len + 2); + if (!len || res.length !== len) { + throw new Error(`Invalid signature integer: wrong length`); + } + if (res[0] === 0x00 && res[1] <= 0x7f) { + throw new Error('Invalid signature integer: trailing length'); + } + return { data: bytesToNumber(res), left: data.subarray(len + 2) }; +} +function parseDERSignature(data) { + if (data.length < 2 || data[0] != 0x30) { + throw new Error(`Invalid signature tag: ${bytesToHex(data)}`); + } + if (data[1] !== data.length - 2) { + throw new Error('Invalid signature: incorrect length'); + } + const { data: r, left: sBytes } = parseDERInt(data.subarray(2)); + const { data: s, left: rBytesLeft } = parseDERInt(sBytes); + if (rBytesLeft.length) { + throw new Error(`Invalid signature: left bytes after parsing: ${bytesToHex(rBytesLeft)}`); + } + return { r, s }; +} +class Signature { + constructor(r, s) { + this.r = r; + this.s = s; + this.assertValidity(); + } + static fromCompact(hex) { + const arr = hex instanceof Uint8Array; + const name = 'Signature.fromCompact'; + if (typeof hex !== 'string' && !arr) + throw new TypeError(`${name}: Expected string or Uint8Array`); + const str = arr ? bytesToHex(hex) : hex; + if (str.length !== 128) + throw new Error(`${name}: Expected 64-byte hex`); + return new Signature(hexToNumber(str.slice(0, 64)), hexToNumber(str.slice(64, 128))); + } + static fromDER(hex) { + const arr = hex instanceof Uint8Array; + if (typeof hex !== 'string' && !arr) + throw new TypeError(`Signature.fromDER: Expected string or Uint8Array`); + const { r, s } = parseDERSignature(arr ? hex : hexToBytes(hex)); + return new Signature(r, s); + } + static fromHex(hex) { + return this.fromDER(hex); + } + assertValidity() { + const { r, s } = this; + if (!isWithinCurveOrder(r)) + throw new Error('Invalid Signature: r must be 0 < r < n'); + if (!isWithinCurveOrder(s)) + throw new Error('Invalid Signature: s must be 0 < s < n'); + } + hasHighS() { + const HALF = CURVE.n >> _1n; + return this.s > HALF; + } + normalizeS() { + return this.hasHighS() ? new Signature(this.r, mod(-this.s, CURVE.n)) : this; + } + toDERRawBytes() { + return hexToBytes(this.toDERHex()); + } + toDERHex() { + const sHex = sliceDER(numberToHexUnpadded(this.s)); + const rHex = sliceDER(numberToHexUnpadded(this.r)); + const sHexL = sHex.length / 2; + const rHexL = rHex.length / 2; + const sLen = numberToHexUnpadded(sHexL); + const rLen = numberToHexUnpadded(rHexL); + const length = numberToHexUnpadded(rHexL + sHexL + 4); + return `30${length}02${rLen}${rHex}02${sLen}${sHex}`; + } + toRawBytes() { + return this.toDERRawBytes(); + } + toHex() { + return this.toDERHex(); + } + toCompactRawBytes() { + return hexToBytes(this.toCompactHex()); + } + toCompactHex() { + return numTo32bStr(this.r) + numTo32bStr(this.s); + } +} +exports.Signature = Signature; +function concatBytes(...arrays) { + if (!arrays.every((b) => b instanceof Uint8Array)) + throw new Error('Uint8Array list expected'); + if (arrays.length === 1) + return arrays[0]; + const length = arrays.reduce((a, arr) => a + arr.length, 0); + const result = new Uint8Array(length); + for (let i = 0, pad = 0; i < arrays.length; i++) { + const arr = arrays[i]; + result.set(arr, pad); + pad += arr.length; + } + return result; +} +const hexes = Array.from({ length: 256 }, (v, i) => i.toString(16).padStart(2, '0')); +function bytesToHex(uint8a) { + if (!(uint8a instanceof Uint8Array)) + throw new Error('Expected Uint8Array'); + let hex = ''; + for (let i = 0; i < uint8a.length; i++) { + hex += hexes[uint8a[i]]; + } + return hex; +} +const POW_2_256 = BigInt('0x10000000000000000000000000000000000000000000000000000000000000000'); +function numTo32bStr(num) { + if (typeof num !== 'bigint') + throw new Error('Expected bigint'); + if (!(_0n <= num && num < POW_2_256)) + throw new Error('Expected number 0 <= n < 2^256'); + return num.toString(16).padStart(64, '0'); +} +function numTo32b(num) { + const b = hexToBytes(numTo32bStr(num)); + if (b.length !== 32) + throw new Error('Error: expected 32 bytes'); + return b; +} +function numberToHexUnpadded(num) { + const hex = num.toString(16); + return hex.length & 1 ? `0${hex}` : hex; +} +function hexToNumber(hex) { + if (typeof hex !== 'string') { + throw new TypeError('hexToNumber: expected string, got ' + typeof hex); + } + return BigInt(`0x${hex}`); +} +function hexToBytes(hex) { + if (typeof hex !== 'string') { + throw new TypeError('hexToBytes: expected string, got ' + typeof hex); + } + if (hex.length % 2) + throw new Error('hexToBytes: received invalid unpadded hex' + hex.length); + const array = new Uint8Array(hex.length / 2); + for (let i = 0; i < array.length; i++) { + const j = i * 2; + const hexByte = hex.slice(j, j + 2); + const byte = Number.parseInt(hexByte, 16); + if (Number.isNaN(byte) || byte < 0) + throw new Error('Invalid byte sequence'); + array[i] = byte; + } + return array; +} +function bytesToNumber(bytes) { + return hexToNumber(bytesToHex(bytes)); +} +function ensureBytes(hex) { + return hex instanceof Uint8Array ? Uint8Array.from(hex) : hexToBytes(hex); +} +function normalizeScalar(num) { + if (typeof num === 'number' && Number.isSafeInteger(num) && num > 0) + return BigInt(num); + if (typeof num === 'bigint' && isWithinCurveOrder(num)) + return num; + throw new TypeError('Expected valid private scalar: 0 < scalar < curve.n'); +} +function mod(a, b = CURVE.P) { + const result = a % b; + return result >= _0n ? result : b + result; +} +function pow2(x, power) { + const { P } = CURVE; + let res = x; + while (power-- > _0n) { + res *= res; + res %= P; + } + return res; +} +function sqrtMod(x) { + const { P } = CURVE; + const _6n = BigInt(6); + const _11n = BigInt(11); + const _22n = BigInt(22); + const _23n = BigInt(23); + const _44n = BigInt(44); + const _88n = BigInt(88); + const b2 = (x * x * x) % P; + const b3 = (b2 * b2 * x) % P; + const b6 = (pow2(b3, _3n) * b3) % P; + const b9 = (pow2(b6, _3n) * b3) % P; + const b11 = (pow2(b9, _2n) * b2) % P; + const b22 = (pow2(b11, _11n) * b11) % P; + const b44 = (pow2(b22, _22n) * b22) % P; + const b88 = (pow2(b44, _44n) * b44) % P; + const b176 = (pow2(b88, _88n) * b88) % P; + const b220 = (pow2(b176, _44n) * b44) % P; + const b223 = (pow2(b220, _3n) * b3) % P; + const t1 = (pow2(b223, _23n) * b22) % P; + const t2 = (pow2(t1, _6n) * b2) % P; + const rt = pow2(t2, _2n); + const xc = (rt * rt) % P; + if (xc !== x) + throw new Error('Cannot find square root'); + return rt; +} +function invert(number, modulo = CURVE.P) { + if (number === _0n || modulo <= _0n) { + throw new Error(`invert: expected positive integers, got n=${number} mod=${modulo}`); + } + let a = mod(number, modulo); + let b = modulo; + let x = _0n, y = _1n, u = _1n, v = _0n; + while (a !== _0n) { + const q = b / a; + const r = b % a; + const m = x - u * q; + const n = y - v * q; + b = a, a = r, x = u, y = v, u = m, v = n; + } + const gcd = b; + if (gcd !== _1n) + throw new Error('invert: does not exist'); + return mod(x, modulo); +} +function invertBatch(nums, p = CURVE.P) { + const scratch = new Array(nums.length); + const lastMultiplied = nums.reduce((acc, num, i) => { + if (num === _0n) + return acc; + scratch[i] = acc; + return mod(acc * num, p); + }, _1n); + const inverted = invert(lastMultiplied, p); + nums.reduceRight((acc, num, i) => { + if (num === _0n) + return acc; + scratch[i] = mod(acc * scratch[i], p); + return mod(acc * num, p); + }, inverted); + return scratch; +} +function bits2int_2(bytes) { + const delta = bytes.length * 8 - groupLen * 8; + const num = bytesToNumber(bytes); + return delta > 0 ? num >> BigInt(delta) : num; +} +function truncateHash(hash, truncateOnly = false) { + const h = bits2int_2(hash); + if (truncateOnly) + return h; + const { n } = CURVE; + return h >= n ? h - n : h; +} +let _sha256Sync; +let _hmacSha256Sync; +class HmacDrbg { + constructor(hashLen, qByteLen) { + this.hashLen = hashLen; + this.qByteLen = qByteLen; + if (typeof hashLen !== 'number' || hashLen < 2) + throw new Error('hashLen must be a number'); + if (typeof qByteLen !== 'number' || qByteLen < 2) + throw new Error('qByteLen must be a number'); + this.v = new Uint8Array(hashLen).fill(1); + this.k = new Uint8Array(hashLen).fill(0); + this.counter = 0; + } + hmac(...values) { + return exports.utils.hmacSha256(this.k, ...values); + } + hmacSync(...values) { + return _hmacSha256Sync(this.k, ...values); + } + checkSync() { + if (typeof _hmacSha256Sync !== 'function') + throw new ShaError('hmacSha256Sync needs to be set'); + } + incr() { + if (this.counter >= 1000) + throw new Error('Tried 1,000 k values for sign(), all were invalid'); + this.counter += 1; + } + async reseed(seed = new Uint8Array()) { + this.k = await this.hmac(this.v, Uint8Array.from([0x00]), seed); + this.v = await this.hmac(this.v); + if (seed.length === 0) + return; + this.k = await this.hmac(this.v, Uint8Array.from([0x01]), seed); + this.v = await this.hmac(this.v); + } + reseedSync(seed = new Uint8Array()) { + this.checkSync(); + this.k = this.hmacSync(this.v, Uint8Array.from([0x00]), seed); + this.v = this.hmacSync(this.v); + if (seed.length === 0) + return; + this.k = this.hmacSync(this.v, Uint8Array.from([0x01]), seed); + this.v = this.hmacSync(this.v); + } + async generate() { + this.incr(); + let len = 0; + const out = []; + while (len < this.qByteLen) { + this.v = await this.hmac(this.v); + const sl = this.v.slice(); + out.push(sl); + len += this.v.length; + } + return concatBytes(...out); + } + generateSync() { + this.checkSync(); + this.incr(); + let len = 0; + const out = []; + while (len < this.qByteLen) { + this.v = this.hmacSync(this.v); + const sl = this.v.slice(); + out.push(sl); + len += this.v.length; + } + return concatBytes(...out); + } +} +function isWithinCurveOrder(num) { + return _0n < num && num < CURVE.n; +} +function isValidFieldElement(num) { + return _0n < num && num < CURVE.P; +} +function kmdToSig(kBytes, m, d, lowS = true) { + const { n } = CURVE; + const k = truncateHash(kBytes, true); + if (!isWithinCurveOrder(k)) + return; + const kinv = invert(k, n); + const q = Point.BASE.multiply(k); + const r = mod(q.x, n); + if (r === _0n) + return; + const s = mod(kinv * mod(m + d * r, n), n); + if (s === _0n) + return; + let sig = new Signature(r, s); + let recovery = (q.x === sig.r ? 0 : 2) | Number(q.y & _1n); + if (lowS && sig.hasHighS()) { + sig = sig.normalizeS(); + recovery ^= 1; + } + return { sig, recovery }; +} +function normalizePrivateKey(key) { + let num; + if (typeof key === 'bigint') { + num = key; + } + else if (typeof key === 'number' && Number.isSafeInteger(key) && key > 0) { + num = BigInt(key); + } + else if (typeof key === 'string') { + if (key.length !== 2 * groupLen) + throw new Error('Expected 32 bytes of private key'); + num = hexToNumber(key); + } + else if (key instanceof Uint8Array) { + if (key.length !== groupLen) + throw new Error('Expected 32 bytes of private key'); + num = bytesToNumber(key); + } + else { + throw new TypeError('Expected valid private key'); + } + if (!isWithinCurveOrder(num)) + throw new Error('Expected private key: 0 < key < n'); + return num; +} +function normalizePublicKey(publicKey) { + if (publicKey instanceof Point) { + publicKey.assertValidity(); + return publicKey; + } + else { + return Point.fromHex(publicKey); + } +} +function normalizeSignature(signature) { + if (signature instanceof Signature) { + signature.assertValidity(); + return signature; + } + try { + return Signature.fromDER(signature); + } + catch (error) { + return Signature.fromCompact(signature); + } +} +function getPublicKey(privateKey, isCompressed = false) { + return Point.fromPrivateKey(privateKey).toRawBytes(isCompressed); +} +exports.getPublicKey = getPublicKey; +function recoverPublicKey(msgHash, signature, recovery, isCompressed = false) { + return Point.fromSignature(msgHash, signature, recovery).toRawBytes(isCompressed); +} +exports.recoverPublicKey = recoverPublicKey; +function isProbPub(item) { + const arr = item instanceof Uint8Array; + const str = typeof item === 'string'; + const len = (arr || str) && item.length; + if (arr) + return len === compressedLen || len === uncompressedLen; + if (str) + return len === compressedLen * 2 || len === uncompressedLen * 2; + if (item instanceof Point) + return true; + return false; +} +function getSharedSecret(privateA, publicB, isCompressed = false) { + if (isProbPub(privateA)) + throw new TypeError('getSharedSecret: first arg must be private key'); + if (!isProbPub(publicB)) + throw new TypeError('getSharedSecret: second arg must be public key'); + const b = normalizePublicKey(publicB); + b.assertValidity(); + return b.multiply(normalizePrivateKey(privateA)).toRawBytes(isCompressed); +} +exports.getSharedSecret = getSharedSecret; +function bits2int(bytes) { + const slice = bytes.length > fieldLen ? bytes.slice(0, fieldLen) : bytes; + return bytesToNumber(slice); +} +function bits2octets(bytes) { + const z1 = bits2int(bytes); + const z2 = mod(z1, CURVE.n); + return int2octets(z2 < _0n ? z1 : z2); +} +function int2octets(num) { + return numTo32b(num); +} +function initSigArgs(msgHash, privateKey, extraEntropy) { + if (msgHash == null) + throw new Error(`sign: expected valid message hash, not "${msgHash}"`); + const h1 = ensureBytes(msgHash); + const d = normalizePrivateKey(privateKey); + const seedArgs = [int2octets(d), bits2octets(h1)]; + if (extraEntropy != null) { + if (extraEntropy === true) + extraEntropy = exports.utils.randomBytes(fieldLen); + const e = ensureBytes(extraEntropy); + if (e.length !== fieldLen) + throw new Error(`sign: Expected ${fieldLen} bytes of extra data`); + seedArgs.push(e); + } + const seed = concatBytes(...seedArgs); + const m = bits2int(h1); + return { seed, m, d }; +} +function finalizeSig(recSig, opts) { + const { sig, recovery } = recSig; + const { der, recovered } = Object.assign({ canonical: true, der: true }, opts); + const hashed = der ? sig.toDERRawBytes() : sig.toCompactRawBytes(); + return recovered ? [hashed, recovery] : hashed; +} +async function sign(msgHash, privKey, opts = {}) { + const { seed, m, d } = initSigArgs(msgHash, privKey, opts.extraEntropy); + const drbg = new HmacDrbg(hashLen, groupLen); + await drbg.reseed(seed); + let sig; + while (!(sig = kmdToSig(await drbg.generate(), m, d, opts.canonical))) + await drbg.reseed(); + return finalizeSig(sig, opts); +} +exports.sign = sign; +function signSync(msgHash, privKey, opts = {}) { + const { seed, m, d } = initSigArgs(msgHash, privKey, opts.extraEntropy); + const drbg = new HmacDrbg(hashLen, groupLen); + drbg.reseedSync(seed); + let sig; + while (!(sig = kmdToSig(drbg.generateSync(), m, d, opts.canonical))) + drbg.reseedSync(); + return finalizeSig(sig, opts); +} +exports.signSync = signSync; +const vopts = { strict: true }; +function verify(signature, msgHash, publicKey, opts = vopts) { + let sig; + try { + sig = normalizeSignature(signature); + msgHash = ensureBytes(msgHash); + } + catch (error) { + return false; + } + const { r, s } = sig; + if (opts.strict && sig.hasHighS()) + return false; + const h = truncateHash(msgHash); + let P; + try { + P = normalizePublicKey(publicKey); + } + catch (error) { + return false; + } + const { n } = CURVE; + const sinv = invert(s, n); + const u1 = mod(h * sinv, n); + const u2 = mod(r * sinv, n); + const R = Point.BASE.multiplyAndAddUnsafe(P, u1, u2); + if (!R) + return false; + const v = mod(R.x, n); + return v === r; +} +exports.verify = verify; +function schnorrChallengeFinalize(ch) { + return mod(bytesToNumber(ch), CURVE.n); +} +class SchnorrSignature { + constructor(r, s) { + this.r = r; + this.s = s; + this.assertValidity(); + } + static fromHex(hex) { + const bytes = ensureBytes(hex); + if (bytes.length !== 64) + throw new TypeError(`SchnorrSignature.fromHex: expected 64 bytes, not ${bytes.length}`); + const r = bytesToNumber(bytes.subarray(0, 32)); + const s = bytesToNumber(bytes.subarray(32, 64)); + return new SchnorrSignature(r, s); + } + assertValidity() { + const { r, s } = this; + if (!isValidFieldElement(r) || !isWithinCurveOrder(s)) + throw new Error('Invalid signature'); + } + toHex() { + return numTo32bStr(this.r) + numTo32bStr(this.s); + } + toRawBytes() { + return hexToBytes(this.toHex()); + } +} +function schnorrGetPublicKey(privateKey) { + return Point.fromPrivateKey(privateKey).toRawX(); +} +class InternalSchnorrSignature { + constructor(message, privateKey, auxRand = exports.utils.randomBytes()) { + if (message == null) + throw new TypeError(`sign: Expected valid message, not "${message}"`); + this.m = ensureBytes(message); + const { x, scalar } = this.getScalar(normalizePrivateKey(privateKey)); + this.px = x; + this.d = scalar; + this.rand = ensureBytes(auxRand); + if (this.rand.length !== 32) + throw new TypeError('sign: Expected 32 bytes of aux randomness'); + } + getScalar(priv) { + const point = Point.fromPrivateKey(priv); + const scalar = point.hasEvenY() ? priv : CURVE.n - priv; + return { point, scalar, x: point.toRawX() }; + } + initNonce(d, t0h) { + return numTo32b(d ^ bytesToNumber(t0h)); + } + finalizeNonce(k0h) { + const k0 = mod(bytesToNumber(k0h), CURVE.n); + if (k0 === _0n) + throw new Error('sign: Creation of signature failed. k is zero'); + const { point: R, x: rx, scalar: k } = this.getScalar(k0); + return { R, rx, k }; + } + finalizeSig(R, k, e, d) { + return new SchnorrSignature(R.x, mod(k + e * d, CURVE.n)).toRawBytes(); + } + error() { + throw new Error('sign: Invalid signature produced'); + } + async calc() { + const { m, d, px, rand } = this; + const tag = exports.utils.taggedHash; + const t = this.initNonce(d, await tag(TAGS.aux, rand)); + const { R, rx, k } = this.finalizeNonce(await tag(TAGS.nonce, t, px, m)); + const e = schnorrChallengeFinalize(await tag(TAGS.challenge, rx, px, m)); + const sig = this.finalizeSig(R, k, e, d); + if (!(await schnorrVerify(sig, m, px))) + this.error(); + return sig; + } + calcSync() { + const { m, d, px, rand } = this; + const tag = exports.utils.taggedHashSync; + const t = this.initNonce(d, tag(TAGS.aux, rand)); + const { R, rx, k } = this.finalizeNonce(tag(TAGS.nonce, t, px, m)); + const e = schnorrChallengeFinalize(tag(TAGS.challenge, rx, px, m)); + const sig = this.finalizeSig(R, k, e, d); + if (!schnorrVerifySync(sig, m, px)) + this.error(); + return sig; + } +} +async function schnorrSign(msg, privKey, auxRand) { + return new InternalSchnorrSignature(msg, privKey, auxRand).calc(); +} +function schnorrSignSync(msg, privKey, auxRand) { + return new InternalSchnorrSignature(msg, privKey, auxRand).calcSync(); +} +function initSchnorrVerify(signature, message, publicKey) { + const raw = signature instanceof SchnorrSignature; + const sig = raw ? signature : SchnorrSignature.fromHex(signature); + if (raw) + sig.assertValidity(); + return { + ...sig, + m: ensureBytes(message), + P: normalizePublicKey(publicKey), + }; +} +function finalizeSchnorrVerify(r, P, s, e) { + const R = Point.BASE.multiplyAndAddUnsafe(P, normalizePrivateKey(s), mod(-e, CURVE.n)); + if (!R || !R.hasEvenY() || R.x !== r) + return false; + return true; +} +async function schnorrVerify(signature, message, publicKey) { + try { + const { r, s, m, P } = initSchnorrVerify(signature, message, publicKey); + const e = schnorrChallengeFinalize(await exports.utils.taggedHash(TAGS.challenge, numTo32b(r), P.toRawX(), m)); + return finalizeSchnorrVerify(r, P, s, e); + } + catch (error) { + return false; + } +} +function schnorrVerifySync(signature, message, publicKey) { + try { + const { r, s, m, P } = initSchnorrVerify(signature, message, publicKey); + const e = schnorrChallengeFinalize(exports.utils.taggedHashSync(TAGS.challenge, numTo32b(r), P.toRawX(), m)); + return finalizeSchnorrVerify(r, P, s, e); + } + catch (error) { + if (error instanceof ShaError) + throw error; + return false; + } +} +exports.schnorr = { + Signature: SchnorrSignature, + getPublicKey: schnorrGetPublicKey, + sign: schnorrSign, + verify: schnorrVerify, + signSync: schnorrSignSync, + verifySync: schnorrVerifySync, +}; +Point.BASE._setWindowSize(8); +const crypto = { + node: nodeCrypto, + web: typeof self === 'object' && 'crypto' in self ? self.crypto : undefined, +}; +const TAGS = { + challenge: 'BIP0340/challenge', + aux: 'BIP0340/aux', + nonce: 'BIP0340/nonce', +}; +const TAGGED_HASH_PREFIXES = {}; +exports.utils = { + bytesToHex, + hexToBytes, + concatBytes, + mod, + invert, + isValidPrivateKey(privateKey) { + try { + normalizePrivateKey(privateKey); + return true; + } + catch (error) { + return false; + } + }, + _bigintTo32Bytes: numTo32b, + _normalizePrivateKey: normalizePrivateKey, + hashToPrivateKey: (hash) => { + hash = ensureBytes(hash); + const minLen = groupLen + 8; + if (hash.length < minLen || hash.length > 1024) { + throw new Error(`Expected valid bytes of private key as per FIPS 186`); + } + const num = mod(bytesToNumber(hash), CURVE.n - _1n) + _1n; + return numTo32b(num); + }, + randomBytes: (bytesLength = 32) => { + if (crypto.web) { + return crypto.web.getRandomValues(new Uint8Array(bytesLength)); + } + else if (crypto.node) { + const { randomBytes } = crypto.node; + return Uint8Array.from(randomBytes(bytesLength)); + } + else { + throw new Error("The environment doesn't have randomBytes function"); + } + }, + randomPrivateKey: () => exports.utils.hashToPrivateKey(exports.utils.randomBytes(groupLen + 8)), + precompute(windowSize = 8, point = Point.BASE) { + const cached = point === Point.BASE ? point : new Point(point.x, point.y); + cached._setWindowSize(windowSize); + cached.multiply(_3n); + return cached; + }, + sha256: async (...messages) => { + if (crypto.web) { + const buffer = await crypto.web.subtle.digest('SHA-256', concatBytes(...messages)); + return new Uint8Array(buffer); + } + else if (crypto.node) { + const { createHash } = crypto.node; + const hash = createHash('sha256'); + messages.forEach((m) => hash.update(m)); + return Uint8Array.from(hash.digest()); + } + else { + throw new Error("The environment doesn't have sha256 function"); + } + }, + hmacSha256: async (key, ...messages) => { + if (crypto.web) { + const ckey = await crypto.web.subtle.importKey('raw', key, { name: 'HMAC', hash: { name: 'SHA-256' } }, false, ['sign']); + const message = concatBytes(...messages); + const buffer = await crypto.web.subtle.sign('HMAC', ckey, message); + return new Uint8Array(buffer); + } + else if (crypto.node) { + const { createHmac } = crypto.node; + const hash = createHmac('sha256', key); + messages.forEach((m) => hash.update(m)); + return Uint8Array.from(hash.digest()); + } + else { + throw new Error("The environment doesn't have hmac-sha256 function"); + } + }, + sha256Sync: undefined, + hmacSha256Sync: undefined, + taggedHash: async (tag, ...messages) => { + let tagP = TAGGED_HASH_PREFIXES[tag]; + if (tagP === undefined) { + const tagH = await exports.utils.sha256(Uint8Array.from(tag, (c) => c.charCodeAt(0))); + tagP = concatBytes(tagH, tagH); + TAGGED_HASH_PREFIXES[tag] = tagP; + } + return exports.utils.sha256(tagP, ...messages); + }, + taggedHashSync: (tag, ...messages) => { + if (typeof _sha256Sync !== 'function') + throw new ShaError('sha256Sync is undefined, you need to set it'); + let tagP = TAGGED_HASH_PREFIXES[tag]; + if (tagP === undefined) { + const tagH = _sha256Sync(Uint8Array.from(tag, (c) => c.charCodeAt(0))); + tagP = concatBytes(tagH, tagH); + TAGGED_HASH_PREFIXES[tag] = tagP; + } + return _sha256Sync(tagP, ...messages); + }, + _JacobianPoint: JacobianPoint, +}; +Object.defineProperties(exports.utils, { + sha256Sync: { + configurable: false, + get() { + return _sha256Sync; + }, + set(val) { + if (!_sha256Sync) + _sha256Sync = val; + }, + }, + hmacSha256Sync: { + configurable: false, + get() { + return _hmacSha256Sync; + }, + set(val) { + if (!_hmacSha256Sync) + _hmacSha256Sync = val; + }, + }, +}); diff --git a/vendor/sha3.js b/vendor/sha3.js new file mode 100644 index 0000000..52c9334 --- /dev/null +++ b/vendor/sha3.js @@ -0,0 +1,662 @@ +/** + * [js-sha3]{@link https://github.com/emn178/js-sha3} + * + * @version 0.9.3 + * @author Chen, Yi-Cyuan [emn178@gmail.com] + * @copyright Chen, Yi-Cyuan 2015-2023 + * @license MIT + */ +/*jslint bitwise: true */ +(function () { + 'use strict'; + + var INPUT_ERROR = 'input is invalid type'; + var FINALIZE_ERROR = 'finalize already called'; + var WINDOW = typeof window === 'object'; + var root = WINDOW ? window : {}; + if (root.JS_SHA3_NO_WINDOW) { + WINDOW = false; + } + var WEB_WORKER = !WINDOW && typeof self === 'object'; + var NODE_JS = !root.JS_SHA3_NO_NODE_JS && typeof process === 'object' && process.versions && process.versions.node; + if (NODE_JS) { + root = global; + } else if (WEB_WORKER) { + root = self; + } + var COMMON_JS = !root.JS_SHA3_NO_COMMON_JS && typeof module === 'object' && module.exports; + var AMD = typeof define === 'function' && define.amd; + var ARRAY_BUFFER = !root.JS_SHA3_NO_ARRAY_BUFFER && typeof ArrayBuffer !== 'undefined'; + var HEX_CHARS = '0123456789abcdef'.split(''); + var SHAKE_PADDING = [31, 7936, 2031616, 520093696]; + var CSHAKE_PADDING = [4, 1024, 262144, 67108864]; + var KECCAK_PADDING = [1, 256, 65536, 16777216]; + var PADDING = [6, 1536, 393216, 100663296]; + var SHIFT = [0, 8, 16, 24]; + var RC = [1, 0, 32898, 0, 32906, 2147483648, 2147516416, 2147483648, 32907, 0, 2147483649, + 0, 2147516545, 2147483648, 32777, 2147483648, 138, 0, 136, 0, 2147516425, 0, + 2147483658, 0, 2147516555, 0, 139, 2147483648, 32905, 2147483648, 32771, + 2147483648, 32770, 2147483648, 128, 2147483648, 32778, 0, 2147483658, 2147483648, + 2147516545, 2147483648, 32896, 2147483648, 2147483649, 0, 2147516424, 2147483648]; + var BITS = [224, 256, 384, 512]; + var SHAKE_BITS = [128, 256]; + var OUTPUT_TYPES = ['hex', 'buffer', 'arrayBuffer', 'array', 'digest']; + var CSHAKE_BYTEPAD = { + '128': 168, + '256': 136 + }; + + + var isArray = root.JS_SHA3_NO_NODE_JS || !Array.isArray + ? function (obj) { + return Object.prototype.toString.call(obj) === '[object Array]'; + } + : Array.isArray; + + var isView = (ARRAY_BUFFER && (root.JS_SHA3_NO_ARRAY_BUFFER_IS_VIEW || !ArrayBuffer.isView)) + ? function (obj) { + return typeof obj === 'object' && obj.buffer && obj.buffer.constructor === ArrayBuffer; + } + : ArrayBuffer.isView; + + // [message: string, isString: bool] + var formatMessage = function (message) { + var type = typeof message; + if (type === 'string') { + return [message, true]; + } + if (type !== 'object' || message === null) { + throw new Error(INPUT_ERROR); + } + if (ARRAY_BUFFER && message.constructor === ArrayBuffer) { + return [new Uint8Array(message), false]; + } + if (!isArray(message) && !isView(message)) { + throw new Error(INPUT_ERROR); + } + return [message, false]; + } + + var empty = function (message) { + return formatMessage(message)[0].length === 0; + }; + + var cloneArray = function (array) { + var newArray = []; + for (var i = 0; i < array.length; ++i) { + newArray[i] = array[i]; + } + return newArray; + } + + var createOutputMethod = function (bits, padding, outputType) { + return function (message) { + return new Keccak(bits, padding, bits).update(message)[outputType](); + }; + }; + + var createShakeOutputMethod = function (bits, padding, outputType) { + return function (message, outputBits) { + return new Keccak(bits, padding, outputBits).update(message)[outputType](); + }; + }; + + var createCshakeOutputMethod = function (bits, padding, outputType) { + return function (message, outputBits, n, s) { + return methods['cshake' + bits].update(message, outputBits, n, s)[outputType](); + }; + }; + + var createKmacOutputMethod = function (bits, padding, outputType) { + return function (key, message, outputBits, s) { + return methods['kmac' + bits].update(key, message, outputBits, s)[outputType](); + }; + }; + + var createOutputMethods = function (method, createMethod, bits, padding) { + for (var i = 0; i < OUTPUT_TYPES.length; ++i) { + var type = OUTPUT_TYPES[i]; + method[type] = createMethod(bits, padding, type); + } + return method; + }; + + var createMethod = function (bits, padding) { + var method = createOutputMethod(bits, padding, 'hex'); + method.create = function () { + return new Keccak(bits, padding, bits); + }; + method.update = function (message) { + return method.create().update(message); + }; + return createOutputMethods(method, createOutputMethod, bits, padding); + }; + + var createShakeMethod = function (bits, padding) { + var method = createShakeOutputMethod(bits, padding, 'hex'); + method.create = function (outputBits) { + return new Keccak(bits, padding, outputBits); + }; + method.update = function (message, outputBits) { + return method.create(outputBits).update(message); + }; + return createOutputMethods(method, createShakeOutputMethod, bits, padding); + }; + + var createCshakeMethod = function (bits, padding) { + var w = CSHAKE_BYTEPAD[bits]; + var method = createCshakeOutputMethod(bits, padding, 'hex'); + method.create = function (outputBits, n, s) { + if (empty(n) && empty(s)) { + return methods['shake' + bits].create(outputBits); + } else { + return new Keccak(bits, padding, outputBits).bytepad([n, s], w); + } + }; + method.update = function (message, outputBits, n, s) { + return method.create(outputBits, n, s).update(message); + }; + return createOutputMethods(method, createCshakeOutputMethod, bits, padding); + }; + + var createKmacMethod = function (bits, padding) { + var w = CSHAKE_BYTEPAD[bits]; + var method = createKmacOutputMethod(bits, padding, 'hex'); + method.create = function (key, outputBits, s) { + return new Kmac(bits, padding, outputBits).bytepad(['KMAC', s], w).bytepad([key], w); + }; + method.update = function (key, message, outputBits, s) { + return method.create(key, outputBits, s).update(message); + }; + return createOutputMethods(method, createKmacOutputMethod, bits, padding); + }; + + var algorithms = [ + { name: 'keccak', padding: KECCAK_PADDING, bits: BITS, createMethod: createMethod }, + { name: 'sha3', padding: PADDING, bits: BITS, createMethod: createMethod }, + { name: 'shake', padding: SHAKE_PADDING, bits: SHAKE_BITS, createMethod: createShakeMethod }, + { name: 'cshake', padding: CSHAKE_PADDING, bits: SHAKE_BITS, createMethod: createCshakeMethod }, + { name: 'kmac', padding: CSHAKE_PADDING, bits: SHAKE_BITS, createMethod: createKmacMethod } + ]; + + var methods = {}, methodNames = []; + + for (var i = 0; i < algorithms.length; ++i) { + var algorithm = algorithms[i]; + var bits = algorithm.bits; + for (var j = 0; j < bits.length; ++j) { + var methodName = algorithm.name + '_' + bits[j]; + methodNames.push(methodName); + methods[methodName] = algorithm.createMethod(bits[j], algorithm.padding); + if (algorithm.name !== 'sha3') { + var newMethodName = algorithm.name + bits[j]; + methodNames.push(newMethodName); + methods[newMethodName] = methods[methodName]; + } + } + } + + function Keccak(bits, padding, outputBits) { + this.blocks = []; + this.s = []; + this.padding = padding; + this.outputBits = outputBits; + this.reset = true; + this.finalized = false; + this.block = 0; + this.start = 0; + this.blockCount = (1600 - (bits << 1)) >> 5; + this.byteCount = this.blockCount << 2; + this.outputBlocks = outputBits >> 5; + this.extraBytes = (outputBits & 31) >> 3; + + for (var i = 0; i < 50; ++i) { + this.s[i] = 0; + } + } + + Keccak.prototype.update = function (message) { + if (this.finalized) { + throw new Error(FINALIZE_ERROR); + } + var result = formatMessage(message); + message = result[0]; + var isString = result[1]; + var blocks = this.blocks, byteCount = this.byteCount, length = message.length, + blockCount = this.blockCount, index = 0, s = this.s, i, code; + + while (index < length) { + if (this.reset) { + this.reset = false; + blocks[0] = this.block; + for (i = 1; i < blockCount + 1; ++i) { + blocks[i] = 0; + } + } + if (isString) { + for (i = this.start; index < length && i < byteCount; ++index) { + code = message.charCodeAt(index); + if (code < 0x80) { + blocks[i >> 2] |= code << SHIFT[i++ & 3]; + } else if (code < 0x800) { + blocks[i >> 2] |= (0xc0 | (code >> 6)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; + } else if (code < 0xd800 || code >= 0xe000) { + blocks[i >> 2] |= (0xe0 | (code >> 12)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | ((code >> 6) & 0x3f)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; + } else { + code = 0x10000 + (((code & 0x3ff) << 10) | (message.charCodeAt(++index) & 0x3ff)); + blocks[i >> 2] |= (0xf0 | (code >> 18)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | ((code >> 12) & 0x3f)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | ((code >> 6) & 0x3f)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; + } + } + } else { + for (i = this.start; index < length && i < byteCount; ++index) { + blocks[i >> 2] |= message[index] << SHIFT[i++ & 3]; + } + } + this.lastByteIndex = i; + if (i >= byteCount) { + this.start = i - byteCount; + this.block = blocks[blockCount]; + for (i = 0; i < blockCount; ++i) { + s[i] ^= blocks[i]; + } + f(s); + this.reset = true; + } else { + this.start = i; + } + } + return this; + }; + + Keccak.prototype.encode = function (x, right) { + var o = x & 255, n = 1; + var bytes = [o]; + x = x >> 8; + o = x & 255; + while (o > 0) { + bytes.unshift(o); + x = x >> 8; + o = x & 255; + ++n; + } + if (right) { + bytes.push(n); + } else { + bytes.unshift(n); + } + this.update(bytes); + return bytes.length; + }; + + Keccak.prototype.encodeString = function (str) { + var result = formatMessage(str); + str = result[0]; + var isString = result[1]; + var bytes = 0, length = str.length; + if (isString) { + for (var i = 0; i < str.length; ++i) { + var code = str.charCodeAt(i); + if (code < 0x80) { + bytes += 1; + } else if (code < 0x800) { + bytes += 2; + } else if (code < 0xd800 || code >= 0xe000) { + bytes += 3; + } else { + code = 0x10000 + (((code & 0x3ff) << 10) | (str.charCodeAt(++i) & 0x3ff)); + bytes += 4; + } + } + } else { + bytes = length; + } + bytes += this.encode(bytes * 8); + this.update(str); + return bytes; + }; + + Keccak.prototype.bytepad = function (strs, w) { + var bytes = this.encode(w); + for (var i = 0; i < strs.length; ++i) { + bytes += this.encodeString(strs[i]); + } + var paddingBytes = (w - bytes % w) % w; + var zeros = []; + zeros.length = paddingBytes; + this.update(zeros); + return this; + }; + + Keccak.prototype.finalize = function () { + if (this.finalized) { + return; + } + this.finalized = true; + var blocks = this.blocks, i = this.lastByteIndex, blockCount = this.blockCount, s = this.s; + blocks[i >> 2] |= this.padding[i & 3]; + if (this.lastByteIndex === this.byteCount) { + blocks[0] = blocks[blockCount]; + for (i = 1; i < blockCount + 1; ++i) { + blocks[i] = 0; + } + } + blocks[blockCount - 1] |= 0x80000000; + for (i = 0; i < blockCount; ++i) { + s[i] ^= blocks[i]; + } + f(s); + }; + + Keccak.prototype.toString = Keccak.prototype.hex = function () { + this.finalize(); + + var blockCount = this.blockCount, s = this.s, outputBlocks = this.outputBlocks, + extraBytes = this.extraBytes, i = 0, j = 0; + var hex = '', block; + while (j < outputBlocks) { + for (i = 0; i < blockCount && j < outputBlocks; ++i, ++j) { + block = s[i]; + hex += HEX_CHARS[(block >> 4) & 0x0F] + HEX_CHARS[block & 0x0F] + + HEX_CHARS[(block >> 12) & 0x0F] + HEX_CHARS[(block >> 8) & 0x0F] + + HEX_CHARS[(block >> 20) & 0x0F] + HEX_CHARS[(block >> 16) & 0x0F] + + HEX_CHARS[(block >> 28) & 0x0F] + HEX_CHARS[(block >> 24) & 0x0F]; + } + if (j % blockCount === 0) { + s = cloneArray(s); + f(s); + i = 0; + } + } + if (extraBytes) { + block = s[i]; + hex += HEX_CHARS[(block >> 4) & 0x0F] + HEX_CHARS[block & 0x0F]; + if (extraBytes > 1) { + hex += HEX_CHARS[(block >> 12) & 0x0F] + HEX_CHARS[(block >> 8) & 0x0F]; + } + if (extraBytes > 2) { + hex += HEX_CHARS[(block >> 20) & 0x0F] + HEX_CHARS[(block >> 16) & 0x0F]; + } + } + return hex; + }; + + Keccak.prototype.arrayBuffer = function () { + this.finalize(); + + var blockCount = this.blockCount, s = this.s, outputBlocks = this.outputBlocks, + extraBytes = this.extraBytes, i = 0, j = 0; + var bytes = this.outputBits >> 3; + var buffer; + if (extraBytes) { + buffer = new ArrayBuffer((outputBlocks + 1) << 2); + } else { + buffer = new ArrayBuffer(bytes); + } + var array = new Uint32Array(buffer); + while (j < outputBlocks) { + for (i = 0; i < blockCount && j < outputBlocks; ++i, ++j) { + array[j] = s[i]; + } + if (j % blockCount === 0) { + s = cloneArray(s); + f(s); + } + } + if (extraBytes) { + array[j] = s[i]; + buffer = buffer.slice(0, bytes); + } + return buffer; + }; + + Keccak.prototype.buffer = Keccak.prototype.arrayBuffer; + + Keccak.prototype.digest = Keccak.prototype.array = function () { + this.finalize(); + + var blockCount = this.blockCount, s = this.s, outputBlocks = this.outputBlocks, + extraBytes = this.extraBytes, i = 0, j = 0; + var array = [], offset, block; + while (j < outputBlocks) { + for (i = 0; i < blockCount && j < outputBlocks; ++i, ++j) { + offset = j << 2; + block = s[i]; + array[offset] = block & 0xFF; + array[offset + 1] = (block >> 8) & 0xFF; + array[offset + 2] = (block >> 16) & 0xFF; + array[offset + 3] = (block >> 24) & 0xFF; + } + if (j % blockCount === 0) { + s = cloneArray(s); + f(s); + } + } + if (extraBytes) { + offset = j << 2; + block = s[i]; + array[offset] = block & 0xFF; + if (extraBytes > 1) { + array[offset + 1] = (block >> 8) & 0xFF; + } + if (extraBytes > 2) { + array[offset + 2] = (block >> 16) & 0xFF; + } + } + return array; + }; + + function Kmac(bits, padding, outputBits) { + Keccak.call(this, bits, padding, outputBits); + } + + Kmac.prototype = new Keccak(); + + Kmac.prototype.finalize = function () { + this.encode(this.outputBits, true); + return Keccak.prototype.finalize.call(this); + }; + + var f = function (s) { + var h, l, n, c0, c1, c2, c3, c4, c5, c6, c7, c8, c9, + b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15, b16, b17, + b18, b19, b20, b21, b22, b23, b24, b25, b26, b27, b28, b29, b30, b31, b32, b33, + b34, b35, b36, b37, b38, b39, b40, b41, b42, b43, b44, b45, b46, b47, b48, b49; + for (n = 0; n < 48; n += 2) { + c0 = s[0] ^ s[10] ^ s[20] ^ s[30] ^ s[40]; + c1 = s[1] ^ s[11] ^ s[21] ^ s[31] ^ s[41]; + c2 = s[2] ^ s[12] ^ s[22] ^ s[32] ^ s[42]; + c3 = s[3] ^ s[13] ^ s[23] ^ s[33] ^ s[43]; + c4 = s[4] ^ s[14] ^ s[24] ^ s[34] ^ s[44]; + c5 = s[5] ^ s[15] ^ s[25] ^ s[35] ^ s[45]; + c6 = s[6] ^ s[16] ^ s[26] ^ s[36] ^ s[46]; + c7 = s[7] ^ s[17] ^ s[27] ^ s[37] ^ s[47]; + c8 = s[8] ^ s[18] ^ s[28] ^ s[38] ^ s[48]; + c9 = s[9] ^ s[19] ^ s[29] ^ s[39] ^ s[49]; + + h = c8 ^ ((c2 << 1) | (c3 >>> 31)); + l = c9 ^ ((c3 << 1) | (c2 >>> 31)); + s[0] ^= h; + s[1] ^= l; + s[10] ^= h; + s[11] ^= l; + s[20] ^= h; + s[21] ^= l; + s[30] ^= h; + s[31] ^= l; + s[40] ^= h; + s[41] ^= l; + h = c0 ^ ((c4 << 1) | (c5 >>> 31)); + l = c1 ^ ((c5 << 1) | (c4 >>> 31)); + s[2] ^= h; + s[3] ^= l; + s[12] ^= h; + s[13] ^= l; + s[22] ^= h; + s[23] ^= l; + s[32] ^= h; + s[33] ^= l; + s[42] ^= h; + s[43] ^= l; + h = c2 ^ ((c6 << 1) | (c7 >>> 31)); + l = c3 ^ ((c7 << 1) | (c6 >>> 31)); + s[4] ^= h; + s[5] ^= l; + s[14] ^= h; + s[15] ^= l; + s[24] ^= h; + s[25] ^= l; + s[34] ^= h; + s[35] ^= l; + s[44] ^= h; + s[45] ^= l; + h = c4 ^ ((c8 << 1) | (c9 >>> 31)); + l = c5 ^ ((c9 << 1) | (c8 >>> 31)); + s[6] ^= h; + s[7] ^= l; + s[16] ^= h; + s[17] ^= l; + s[26] ^= h; + s[27] ^= l; + s[36] ^= h; + s[37] ^= l; + s[46] ^= h; + s[47] ^= l; + h = c6 ^ ((c0 << 1) | (c1 >>> 31)); + l = c7 ^ ((c1 << 1) | (c0 >>> 31)); + s[8] ^= h; + s[9] ^= l; + s[18] ^= h; + s[19] ^= l; + s[28] ^= h; + s[29] ^= l; + s[38] ^= h; + s[39] ^= l; + s[48] ^= h; + s[49] ^= l; + + b0 = s[0]; + b1 = s[1]; + b32 = (s[11] << 4) | (s[10] >>> 28); + b33 = (s[10] << 4) | (s[11] >>> 28); + b14 = (s[20] << 3) | (s[21] >>> 29); + b15 = (s[21] << 3) | (s[20] >>> 29); + b46 = (s[31] << 9) | (s[30] >>> 23); + b47 = (s[30] << 9) | (s[31] >>> 23); + b28 = (s[40] << 18) | (s[41] >>> 14); + b29 = (s[41] << 18) | (s[40] >>> 14); + b20 = (s[2] << 1) | (s[3] >>> 31); + b21 = (s[3] << 1) | (s[2] >>> 31); + b2 = (s[13] << 12) | (s[12] >>> 20); + b3 = (s[12] << 12) | (s[13] >>> 20); + b34 = (s[22] << 10) | (s[23] >>> 22); + b35 = (s[23] << 10) | (s[22] >>> 22); + b16 = (s[33] << 13) | (s[32] >>> 19); + b17 = (s[32] << 13) | (s[33] >>> 19); + b48 = (s[42] << 2) | (s[43] >>> 30); + b49 = (s[43] << 2) | (s[42] >>> 30); + b40 = (s[5] << 30) | (s[4] >>> 2); + b41 = (s[4] << 30) | (s[5] >>> 2); + b22 = (s[14] << 6) | (s[15] >>> 26); + b23 = (s[15] << 6) | (s[14] >>> 26); + b4 = (s[25] << 11) | (s[24] >>> 21); + b5 = (s[24] << 11) | (s[25] >>> 21); + b36 = (s[34] << 15) | (s[35] >>> 17); + b37 = (s[35] << 15) | (s[34] >>> 17); + b18 = (s[45] << 29) | (s[44] >>> 3); + b19 = (s[44] << 29) | (s[45] >>> 3); + b10 = (s[6] << 28) | (s[7] >>> 4); + b11 = (s[7] << 28) | (s[6] >>> 4); + b42 = (s[17] << 23) | (s[16] >>> 9); + b43 = (s[16] << 23) | (s[17] >>> 9); + b24 = (s[26] << 25) | (s[27] >>> 7); + b25 = (s[27] << 25) | (s[26] >>> 7); + b6 = (s[36] << 21) | (s[37] >>> 11); + b7 = (s[37] << 21) | (s[36] >>> 11); + b38 = (s[47] << 24) | (s[46] >>> 8); + b39 = (s[46] << 24) | (s[47] >>> 8); + b30 = (s[8] << 27) | (s[9] >>> 5); + b31 = (s[9] << 27) | (s[8] >>> 5); + b12 = (s[18] << 20) | (s[19] >>> 12); + b13 = (s[19] << 20) | (s[18] >>> 12); + b44 = (s[29] << 7) | (s[28] >>> 25); + b45 = (s[28] << 7) | (s[29] >>> 25); + b26 = (s[38] << 8) | (s[39] >>> 24); + b27 = (s[39] << 8) | (s[38] >>> 24); + b8 = (s[48] << 14) | (s[49] >>> 18); + b9 = (s[49] << 14) | (s[48] >>> 18); + + s[0] = b0 ^ (~b2 & b4); + s[1] = b1 ^ (~b3 & b5); + s[10] = b10 ^ (~b12 & b14); + s[11] = b11 ^ (~b13 & b15); + s[20] = b20 ^ (~b22 & b24); + s[21] = b21 ^ (~b23 & b25); + s[30] = b30 ^ (~b32 & b34); + s[31] = b31 ^ (~b33 & b35); + s[40] = b40 ^ (~b42 & b44); + s[41] = b41 ^ (~b43 & b45); + s[2] = b2 ^ (~b4 & b6); + s[3] = b3 ^ (~b5 & b7); + s[12] = b12 ^ (~b14 & b16); + s[13] = b13 ^ (~b15 & b17); + s[22] = b22 ^ (~b24 & b26); + s[23] = b23 ^ (~b25 & b27); + s[32] = b32 ^ (~b34 & b36); + s[33] = b33 ^ (~b35 & b37); + s[42] = b42 ^ (~b44 & b46); + s[43] = b43 ^ (~b45 & b47); + s[4] = b4 ^ (~b6 & b8); + s[5] = b5 ^ (~b7 & b9); + s[14] = b14 ^ (~b16 & b18); + s[15] = b15 ^ (~b17 & b19); + s[24] = b24 ^ (~b26 & b28); + s[25] = b25 ^ (~b27 & b29); + s[34] = b34 ^ (~b36 & b38); + s[35] = b35 ^ (~b37 & b39); + s[44] = b44 ^ (~b46 & b48); + s[45] = b45 ^ (~b47 & b49); + s[6] = b6 ^ (~b8 & b0); + s[7] = b7 ^ (~b9 & b1); + s[16] = b16 ^ (~b18 & b10); + s[17] = b17 ^ (~b19 & b11); + s[26] = b26 ^ (~b28 & b20); + s[27] = b27 ^ (~b29 & b21); + s[36] = b36 ^ (~b38 & b30); + s[37] = b37 ^ (~b39 & b31); + s[46] = b46 ^ (~b48 & b40); + s[47] = b47 ^ (~b49 & b41); + s[8] = b8 ^ (~b0 & b2); + s[9] = b9 ^ (~b1 & b3); + s[18] = b18 ^ (~b10 & b12); + s[19] = b19 ^ (~b11 & b13); + s[28] = b28 ^ (~b20 & b22); + s[29] = b29 ^ (~b21 & b23); + s[38] = b38 ^ (~b30 & b32); + s[39] = b39 ^ (~b31 & b33); + s[48] = b48 ^ (~b40 & b42); + s[49] = b49 ^ (~b41 & b43); + + s[0] ^= RC[n]; + s[1] ^= RC[n + 1]; + } + }; + + if (COMMON_JS) { + module.exports = methods; + } else { + for (i = 0; i < methodNames.length; ++i) { + root[methodNames[i]] = methods[methodNames[i]]; + } + if (AMD) { + define(function () { + return methods; + }); + } + } +})(); diff --git a/videomaker.js b/videomaker.js new file mode 100644 index 0000000..ee22a7d --- /dev/null +++ b/videomaker.js @@ -0,0 +1,146 @@ +// Video Maker (Circuit and up, Marty 2026-09-14): the promo videos re-rendered with the member's own end +// card (username, invite link, QR) so the last four seconds carry their link, not the company's. +// ffmpeg + a DejaVu font in the image (Dockerfile). One job at a time; sources are the hosted promo +// videos in the Spaces bucket; output is hosted at promo/made//.mp4 and cached forever +// (a member's link never changes). Jobs live in DATA_DIR/video-jobs.json. +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const os = require('os'); +const { spawn } = require('child_process'); +let R = null; // { dataDir, spaces, accounts } +let QR = null; try { QR = require('qrcode'); } catch (e) {} +const BASE = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/'; +const SOURCES = [ + { slug: 'instant', title: 'Paid before the page reloads (16:9)', url: BASE + 'instant.mp4', kind: 'landscape' }, + { slug: 'instant-portrait', title: 'Paid before the page reloads (9:16)', url: BASE + 'instant-portrait.mp4', kind: 'portrait' }, + { slug: 'adspend', title: 'You were buying traffic anyway (16:9)', url: BASE + 'adspend.mp4', kind: 'landscape' }, + { slug: 'adspend-portrait', title: 'You were buying traffic anyway (9:16)', url: BASE + 'adspend-portrait.mp4', kind: 'portrait' }, + { slug: 'free', title: 'Watch first, spend never (16:9)', url: BASE + 'free.mp4', kind: 'landscape' }, + { slug: 'free-portrait', title: 'Watch first, spend never (9:16)', url: BASE + 'free-portrait.mp4', kind: 'portrait' }, + { slug: 'ledger', title: 'No back office. No payday. (16:9)', url: BASE + 'ledger.mp4', kind: 'landscape' }, + { slug: 'ledger-portrait', title: 'No back office. No payday. (9:16)', url: BASE + 'ledger-portrait.mp4', kind: 'portrait' }, + { slug: 'two', title: 'Two buyers open level two (16:9)', url: BASE + 'two.mp4', kind: 'landscape' }, + { slug: 'two-portrait', title: 'Two buyers open level two (9:16)', url: BASE + 'two-portrait.mp4', kind: 'portrait' }, + { slug: 's01-instant', title: 'Short: Paid before the page reloads', url: BASE + 'shorts/s01-instant.mp4', kind: 'portrait' }, + { slug: 's02-free', title: 'Short: Try it without spending a dollar', url: BASE + 'shorts/s02-free.mp4', kind: 'portrait' }, + { slug: 's03-two', title: 'Short: Two buyers open level two', url: BASE + 'shorts/s03-two.mp4', kind: 'portrait' }, + { slug: 's04-ledger', title: 'Short: Just a public ledger', url: BASE + 'shorts/s04-ledger.mp4', kind: 'portrait' }, + { slug: 's05-adspend', title: 'Short: The ad spend pays you back', url: BASE + 'shorts/s05-adspend.mp4', kind: 'portrait' }, + { slug: 's06-passive', title: 'Short: Is it passive income? The honest answer', url: BASE + 'shorts/s06-passive.mp4', kind: 'portrait' }, + { slug: 's07-immutable', title: 'Short: Nobody can change the split', url: BASE + 'shorts/s07-immutable.mp4', kind: 'portrait' }, + { slug: 's08-network', title: 'Short: One budget, the whole network', url: BASE + 'shorts/s08-network.mp4', kind: 'portrait' }, + { slug: 's09-tank', title: 'Short: The holding tank', url: BASE + 'shorts/s09-tank.mp4', kind: 'portrait' }, + { slug: 's10-leader', title: 'Short: The leader play', url: BASE + 'shorts/s10-leader.mp4', kind: 'portrait' } +]; +const FILE = () => path.join(R.dataDir, 'video-jobs.json'); +function jobs() { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { return []; } } +function saveJobs(j) { try { fs.writeFileSync(FILE(), JSON.stringify(j.slice(-500))); } catch (e) {} } +function init(refs) { R = refs; setInterval(() => tick().catch(e => console.error('videomaker', e.message)), 5000); } +let FONT = undefined; // Alpine puts ttf-dejavu under /usr/share/fonts/ttf-dejavu (older) or /dejavu (newer): search once +function font() { if (FONT !== undefined) return FONT; if (process.env.VIDEOMAKER_FONT) { FONT = process.env.VIDEOMAKER_FONT; return FONT; } FONT = null; const walk = d => { let ents = []; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch (e) { return null; } for (const e of ents) { const f = path.join(d, e.name); if (e.isDirectory()) { const r = walk(f); if (r) return r; } else if (e.name === 'DejaVuSans-Bold.ttf') return f; } return null; }; FONT = walk('/usr/share/fonts'); return FONT; } +// progress: 0-100 plus a short stage label, written to the jobs file so the page can poll it +function setProgress(id, pct, stage) { const j = jobs(); const k = j.find(x => x.id === id); if (!k) return; k.pct = Math.max(k.pct || 0, Math.min(100, Math.round(pct))); if (stage) k.stage = stage; saveJobs(j); } +function durationOf(file) { + return new Promise(resolve => { const p = spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file]); let out = ''; p.stdout.on('data', c => out += c); p.on('close', () => resolve(parseFloat(out) || 0)); p.on('error', () => resolve(0)); }); +} +function available() { return !!(font() && R && R.spaces && R.spaces.enabled()); } + +function run(cmd, args, timeoutMs, onOut) { + return new Promise((resolve, reject) => { + const p = spawn(cmd, args, { stdio: ['ignore', onOut ? 'pipe' : 'ignore', 'pipe'] }); let err = ''; + if (onOut) p.stdout.on('data', c => { try { onOut(String(c)); } catch (e) {} }); + const t = setTimeout(() => { p.kill('SIGKILL'); reject(new Error(cmd + ' timeout')); }, timeoutMs || 240000); + p.stderr.on('data', c => { err += c; if (err.length > 20000) err = err.slice(-10000); }); + p.on('error', e => { clearTimeout(t); reject(e); }); + p.on('close', code => { clearTimeout(t); code === 0 ? resolve() : reject(new Error(cmd + ' exit ' + code + ': ' + err.slice(-400))); }); + }); +} +function download(url, file) { + return new Promise((resolve, reject) => { + const out = fs.createWriteStream(file); + https.get(url, res => { if (res.statusCode !== 200) return reject(new Error('download ' + res.statusCode)); res.pipe(out); out.on('finish', () => out.close(resolve)); }).on('error', reject); + }); +} +function probe(file) { + return new Promise((resolve, reject) => { + const p = spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', file]); + let out = ''; p.stdout.on('data', c => out += c); p.on('close', () => { const m = /(\d+),(\d+)/.exec(out); m ? resolve({ w: Number(m[1]), h: Number(m[2]) }) : reject(new Error('probe failed')); }); + }); +} +const ffText = s => String(s).replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'").replace(/%/g, '\\%'); + +// list for the UI: every source with the member's finished video, if made +function queuedAhead(job) { return jobs().filter(x => x.status === 'queued' && x.at < job.at).length; } +function list(username) { + const done = {}; for (const j of jobs()) if (j.username === username && j.status === 'done') done[j.slug] = j; + const pending = {}; for (const j of jobs()) if (j.username === username && (j.status === 'queued' || j.status === 'working')) pending[j.slug] = j; + return SOURCES.map(s => ({ slug: s.slug, title: s.title, kind: s.kind, url: done[s.slug] ? done[s.slug].url : null, madeAt: done[s.slug] ? done[s.slug].doneAt : null, status: pending[s.slug] ? pending[s.slug].status : (done[s.slug] ? 'done' : 'none'), pct: pending[s.slug] ? (pending[s.slug].status === 'queued' ? 0 : pending[s.slug].pct || 0) : 0, stage: pending[s.slug] ? (pending[s.slug].status === 'queued' ? 'Waiting for the renderer' + (queuedAhead(pending[s.slug]) ? ' (' + queuedAhead(pending[s.slug]) + ' ahead of you)' : '') : pending[s.slug].stage || 'Starting') : null })); +} +function enqueue(email, username, slug) { + if (!available()) return { error: 'The Video Maker is not set up on this server yet.' }; + const src = SOURCES.find(s => s.slug === slug); if (!src) return { error: 'Pick a video.' }; + const j = jobs(); + if (j.find(x => x.username === username && x.slug === slug && (x.status === 'queued' || x.status === 'working'))) return { ok: true, status: 'queued' }; + const d = j.find(x => x.username === username && x.slug === slug && x.status === 'done'); if (d) return { ok: true, status: 'done', url: d.url }; + if (j.filter(x => x.status === 'queued').length > 30) return { error: 'The render queue is full right now. Try again in a few minutes.' }; + j.push({ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), email, username, slug, status: 'queued', at: Date.now() }); + saveJobs(j); return { ok: true, status: 'queued', ahead: j.filter(x => x.status === 'queued').length - 1 }; +} + +let busy = false; +async function tick() { + if (busy || !R || !available()) return; + const j = jobs(); const job = j.find(x => x.status === 'queued'); if (!job) return; + busy = true; + try { + job.status = 'working'; job.startedAt = Date.now(); saveJobs(j); + const url = await render(job); + const jj = jobs(); const k = jj.find(x => x.id === job.id); if (k) { k.status = 'done'; k.url = url; k.doneAt = Date.now(); saveJobs(jj); } + console.log('videomaker done', job.username, job.slug, url); + } catch (e) { + const jj = jobs(); const k = jj.find(x => x.id === job.id); if (k) { k.status = 'failed'; k.error = String(e.message || e).slice(0, 200); k.doneAt = Date.now(); saveJobs(jj); } + console.error('videomaker failed', job.username, job.slug, e.message); + } finally { busy = false; } +} +async function render(job) { + const src = SOURCES.find(s => s.slug === job.slug); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-')); + const srcFile = path.join(dir, 'src.mp4'), qrFile = path.join(dir, 'qr.png'), endFile = path.join(dir, 'end.mp4'), outFile = path.join(dir, 'out.mp4'); + try { + setProgress(job.id, 3, 'Fetching the video'); + await download(src.url, srcFile); + const { w, h } = await probe(srcFile); const total = (await durationOf(srcFile)) + 4.5; + setProgress(job.id, 20, 'Drawing your end card'); + const link = 'linkspin-test.saasy.top/join/' + job.username; + await QR.toFile(qrFile, 'https://' + link, { width: Math.round(Math.min(w, h) * 0.34), margin: 1, color: { dark: '#061c17', light: '#ffffff' } }); + const F = font(); const portrait = h > w; const big = Math.round(Math.min(w, h) * (portrait ? 0.075 : 0.07)), mid = Math.round(big * 0.62), small = Math.round(big * 0.48); + const qrSize = Math.round(Math.min(w, h) * 0.34); + // every line is sized to fit 90% of the frame width (DejaVu Bold averages ~0.62em per glyph); portrait splits the long lines in two + const maxW = Math.round(w * 0.9); const fit = (t, max) => Math.max(18, Math.min(max, Math.floor(maxW / (t.length * 0.62)))); + const titles = portrait ? ['Join my line', 'on LinkSpin'] : ['Join my line on LinkSpin']; + const foots = portrait ? ['Free to join. Paid in the same transaction.', 'Not investment advice.'] : ['Free to join. Paid in the same transaction. Not investment advice.']; + const bigS = Math.min(...titles.map(t => fit(t, big))), midS = fit('@' + job.username, mid), smallS = fit(link, small), footS = Math.min(...foots.map(t => fit(t, Math.round(small * 0.8)))); + const yTitle = Math.round(h * (portrait ? 0.16 : 0.14)), yQr = Math.round(h * 0.30), yName = yQr + qrSize + Math.round(h * 0.03), yLink = yName + midS + Math.round(h * 0.015), yFootEnd = h - Math.round(h * 0.08); + const draw = (t, color, size, y) => 'drawtext=fontfile=' + F + ":text='" + ffText(t) + "':fontcolor=" + color + ':fontsize=' + size + ':x=(w-text_w)/2:y=' + y; + const steps = []; + titles.forEach((t, i) => steps.push(draw(t, '0x43e8c3', bigS, yTitle + Math.round(i * bigS * 1.2)))); + steps.push(draw('@' + job.username, '0xffffff', midS, yName)); + steps.push(draw(link, '0xffd15c', smallS, yLink)); + foots.forEach((t, i) => steps.push(draw(t, '0x8fd8c4', footS, yFootEnd - Math.round((foots.length - 1 - i) * footS * 1.35)))); + const filters = [ + '[0:v][1:v]overlay=(W-w)/2:' + yQr + '[b]', + '[b]' + steps.join(',') + ',format=yuv420p[v]' + ].join(';'); + await run('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=0x061c17:s=' + w + 'x' + h + ':r=30:d=4.5', '-i', qrFile, '-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=stereo', '-filter_complex', filters, '-map', '[v]', '-map', '2:a', '-t', '4.5', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '24', '-c:a', 'aac', '-shortest', endFile], 120000); + setProgress(job.id, 30, 'Stitching your card onto the video'); + await run('ffmpeg', ['-y', '-loglevel', 'error', '-progress', 'pipe:1', '-i', srcFile, '-i', endFile, '-filter_complex', + '[0:v]fps=30,scale=' + w + ':' + h + ',format=yuv420p,setsar=1[v0];[0:a]aformat=sample_rates=44100:channel_layouts=stereo[a0];[1:v]fps=30,scale=' + w + ':' + h + ',format=yuv420p,setsar=1[v1];[1:a]aformat=sample_rates=44100:channel_layouts=stereo[a1];[v0][a0][v1][a1]concat=n=2:v=1:a=1[v][a]', + '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '25', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', outFile], 420000, + out => { const m = /out_time_ms=(\d+)/g; let last = null, x; while ((x = m.exec(out))) last = x[1]; if (last && total) setProgress(job.id, 30 + 60 * Math.min(1, (Number(last) / 1e6) / total)); }); + setProgress(job.id, 92, 'Uploading'); + const key = 'promo/made/' + job.username + '/' + job.slug + '.mp4'; + return await R.spaces.put(key, fs.readFileSync(outFile), 'video/mp4'); + } finally { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) {} } +} +module.exports = { init, list, enqueue, available, SOURCES };