Compare commits
225 Commits
94910275c0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9116368ffe | |||
| d2a3f162d0 | |||
| 742c271469 | |||
| 18aabb3cb2 | |||
| 5b694c8ab4 | |||
| 1b72a9a18c | |||
| f2a999edd0 | |||
| 3f9dc3cc94 | |||
| fd08af36ba | |||
| 170d9963f7 | |||
| 2854e2a7ba | |||
| 3d4b07a8ab | |||
| 78892421bb | |||
| 869eee809e | |||
| 6ff21f76ee | |||
| 8656f7ce4d | |||
| 5c08be1c30 | |||
| 02d60d6ee1 | |||
| f0a6a93f8b | |||
| 891eac1626 | |||
| 489d832546 | |||
| 286ac9207a | |||
| ce9f3e6904 | |||
| 68a92c39ff | |||
| f255d096ad | |||
| 5c11bfa502 | |||
| c47905d89a | |||
| 67fb8193bd | |||
| 635844a12b | |||
| 9df669fa7c | |||
| a403ce3520 | |||
| 260daaf9d0 | |||
| c6c26b8073 | |||
| e5cbecbdb2 | |||
| 269247c871 | |||
| e2f88dad42 | |||
| 337d5dd556 | |||
| 6f5b5dbae6 | |||
| 91456ae0ce | |||
| 68b08ab8cc | |||
| de5bdfefaf | |||
| 9fafd0bf43 | |||
| 9a531538ed | |||
| 7ea7a5f409 | |||
| ad539d5fae | |||
| 5fe3dfa625 | |||
| 49a92461f0 | |||
| 240a928323 | |||
| af710f68b8 | |||
| 0d2621defd | |||
| c00ec64cf8 | |||
| d90f08d54a | |||
| a85ee4cacf | |||
| f282e328b5 | |||
| 415a5f3848 | |||
| 5ebe890760 | |||
| 70c4053201 | |||
| 39572c0ff5 | |||
| b061053ccf | |||
| 0a50d07658 | |||
| ccc372152d | |||
| c5a846bd91 | |||
| a2c77d01d1 | |||
| a84b851272 | |||
| 71ab9555e2 | |||
| d4e2f59f9b | |||
| dc0f0d056d | |||
| 3fb123393a | |||
| cb6da01e1c | |||
| e95f9df13a | |||
| 2dfccf0039 | |||
| f27388a322 | |||
| 3867b28bd4 | |||
| 6c8cc0ede2 | |||
| 8ad2e01a74 | |||
| a2cea3a2be | |||
| aab18e28ca | |||
| 34c62b9d3a | |||
| 125be174e3 | |||
| 9b3a79561b | |||
| 2b4050d410 | |||
| 27f2742c31 | |||
| 362526d007 | |||
| 5641582709 | |||
| d6bc79c9d6 | |||
| b811b96e33 | |||
| 230c250c64 | |||
| a727ab1ce5 | |||
| dffcaa113b | |||
| a85a198e95 | |||
| dda8c976b0 | |||
| 616f328db5 | |||
| 69d672665c | |||
| ffacf741da | |||
| 28bce75913 | |||
| b1a1cbff5e | |||
| 84d9f49d4b | |||
| b7cc06b28c | |||
| 0da7c796b8 | |||
| 4d578fadac | |||
| 310384cdad | |||
| 66b3c83d3d | |||
| 92e7304882 | |||
| 8ca019a5b3 | |||
| 560d758ed9 | |||
| 02f742f90a | |||
| 56aa7418ad | |||
| d6d771d93e | |||
| 87fa837165 | |||
| 653c6d486e | |||
| c5e4dc423b | |||
| e03dbbffcf | |||
| 84a9063bbe | |||
| 86a7f83874 | |||
| 7423347c95 | |||
| a19a3fc31e | |||
| f155a8cc24 | |||
| 1533d377cc | |||
| 6e8f1ccf9d | |||
| 14868753c0 | |||
| 024170e7f8 | |||
| 7bb956e896 | |||
| 203ebdb4bf | |||
| 119644d688 | |||
| 0a19e77e7f | |||
| 1fad5bdab2 | |||
| e699513636 | |||
| f2c155a6d2 | |||
| 201d249738 | |||
| 32028b1e8e | |||
| d57fc40299 | |||
| 96760158df | |||
| 9316dfc0db | |||
| 0f030a4943 | |||
| c1f58b2acf | |||
| b09e5dce8b | |||
| 825d0de6c3 | |||
| 6233d112e0 | |||
| b0ef3b3a58 | |||
| b3523e9dc4 | |||
| f7b911bab6 | |||
| 8e415c3cf4 | |||
| e63bc57a99 | |||
| 964a02a342 | |||
| 0c58b80184 | |||
| b9a70dba1a | |||
| 34098e5565 | |||
| 6589930945 | |||
| 6ca287042f | |||
| b5eb03456c | |||
| 53f87f79ba | |||
| 0ad974ca59 | |||
| 4efba20d4e | |||
| 0c7f957db4 | |||
| 1faa43780e | |||
| 4caa0ef5a8 | |||
| 2bd612d9ba | |||
| 49a98e7ee3 | |||
| 1a83f4f2ff | |||
| a6dcd02999 | |||
| 9d14af0898 | |||
| 5b4fe133eb | |||
| 99f6c1f8c6 | |||
| 6395ad3a7e | |||
| a9f10f861d | |||
| a385fbf4c2 | |||
| 6996186061 | |||
| 2a96403ffd | |||
| fb709ddde9 | |||
| 3c81df2167 | |||
| 65ef42fc24 | |||
| d6b85f0ce9 | |||
| c2402c4e0e | |||
| 551b860c67 | |||
| 9898e3f47f | |||
| 6498d85b20 | |||
| eaaa87281f | |||
| fc55714757 | |||
| 9b60e6414e | |||
| ebac2fcb32 | |||
| 261f90d843 | |||
| 0cfbe67196 | |||
| ef088601c3 | |||
| abc76c47c8 | |||
| e46780f872 | |||
| eae2a04dc0 | |||
| b844a227d3 | |||
| 1fd61eca56 | |||
| 024027a2da | |||
| 63bedddc94 | |||
| cd26161db5 | |||
| 0f855292f5 | |||
| fbd83bf933 | |||
| 78e4c76a89 | |||
| c9f7a50b30 | |||
| ab306937e1 | |||
| 8b16d52ca6 | |||
| fe1edd945c | |||
| b97e139ef8 | |||
| 09dc6ed368 | |||
| 9709ce782d | |||
| 9939c0487f | |||
| 6a21b4dc62 | |||
| cdb580c1f4 | |||
| 48dcd71290 | |||
| bf5371ab20 | |||
| 159e19dcdf | |||
| f9b198a49b | |||
| f7c840dee4 | |||
| 496133b99e | |||
| fe9661ec33 | |||
| 129e4d0946 | |||
| 00f85fde08 | |||
| 04e42e94ff | |||
| 4addf7718e | |||
| f5fb195927 | |||
| b4b9ac8680 | |||
| cac78133ca | |||
| 4487a90d16 | |||
| af6d2db711 | |||
| 38366e0beb | |||
| 82269eccc8 | |||
| ecdee89be1 | |||
| 6e187c4f41 | |||
| abf7415c83 |
@@ -1,4 +1,5 @@
|
||||
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
|
||||
|
||||
@@ -8,7 +8,7 @@ Node server (RM Circle pattern): static pages + JSON API + SSE ledger.
|
||||
- `server.js` — http server: pages, `/api/*`, `/join/<id>` 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, first-touch sponsor
|
||||
- `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
|
||||
|
||||
@@ -30,3 +30,10 @@ 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/`.
|
||||
|
||||
## Chatbot knowledge is part of every change
|
||||
|
||||
`chatbot.js` answers members two ways: `CANNED` regex answers and `systemPrompt()` for the AI. Any
|
||||
change that a member could ask about (a new pane, a rule, a price, a limit, a fix they noticed) is not
|
||||
done until both are updated in the same commit, and `KNOWLEDGE_DATE` in `chatbot.js` is bumped. The QA
|
||||
runner (`bash qa/run.sh public`) fails when a release note on the live site is newer than that date.
|
||||
|
||||
@@ -33,7 +33,7 @@ function newCode(taken) {
|
||||
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,
|
||||
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,
|
||||
@@ -75,11 +75,11 @@ const J = {
|
||||
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) {
|
||||
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 };
|
||||
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();
|
||||
@@ -168,6 +168,10 @@ const J = {
|
||||
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); },
|
||||
// every account's identity, never a page of them: a lookup built from a capped list silently
|
||||
// calls every older member unreachable (the admin table labelled 144 live sponsors 'dead link'
|
||||
// once the site passed 500 accounts, Marty 2026-09-23)
|
||||
async identities() { return Object.values(this.db.byEmail).map(a => ({ email: a.email, username: a.username || null, code: a.code || null, memberId: a.memberId || 0 })); },
|
||||
async setSponsorRef(e, ref) {
|
||||
const acct = this.db.byEmail[e];
|
||||
if (!acct) return { error: 'No such account.' };
|
||||
@@ -194,17 +198,35 @@ const J = {
|
||||
},
|
||||
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,
|
||||
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),
|
||||
@@ -228,12 +250,12 @@ const D = {
|
||||
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) {
|
||||
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) VALUES (?,NULL,?,?,NULL,?,?)',
|
||||
[e, ref, code, Date.now(), via || null]);
|
||||
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;
|
||||
@@ -315,6 +337,10 @@ const D = {
|
||||
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); },
|
||||
// identity columns for EVERY account, no limit: small enough to stay cheap at any size, and a
|
||||
// sponsor lookup must never be built from a page of members (see the JSON note above)
|
||||
async identities() { const rows = await db.q('SELECT email, username, code, member_id FROM accounts');
|
||||
return rows.map(r => ({ email: r.email, username: r.username || null, code: r.code || null, memberId: Number(r.member_id) || 0 })); },
|
||||
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.' };
|
||||
@@ -342,12 +368,25 @@ const D = {
|
||||
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;
|
||||
@@ -360,10 +399,10 @@ async function signup(email, password, sponsorRef) {
|
||||
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) {
|
||||
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);
|
||||
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)); }
|
||||
@@ -414,6 +453,7 @@ 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 identities() { return impl().identities(); }
|
||||
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)
|
||||
@@ -438,12 +478,15 @@ async function getChatSettings(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, listAll, setSponsorRef,
|
||||
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername, byMemberId: id => impl().byMemberId(Number(id) || 0), listAll, identities, 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),
|
||||
@@ -452,5 +495,6 @@ module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUs
|
||||
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) };
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,118 @@
|
||||
// Syndicate member campaigns to AdRevLinks as popup ads.
|
||||
//
|
||||
// The AdRevLinks database is NOT reachable from this server (port 3306 on that box is
|
||||
// closed to us), so we talk to a narrow authenticated endpoint that runs there and does
|
||||
// the insert locally. It exposes exactly five actions and no general query surface, so a
|
||||
// leaked key can only create or remove popup campaigns.
|
||||
//
|
||||
// FEATURE-FLAGGED: inert unless ADREVLNKS_BRIDGE_URL and ADREVLNKS_BRIDGE_KEY are set.
|
||||
//
|
||||
// Two things learned from the Network Ad Space integration, deliberately repeated here:
|
||||
//
|
||||
// 1. DELIVERY IS READ, NEVER DERIVED. `status` returns the rotator's own per-country view
|
||||
// counters. We never compute "served" from a number that a stop or cancel could
|
||||
// overwrite, which is exactly what charged members for undelivered impressions there.
|
||||
//
|
||||
// 2. EVERY WRITE IS IDEMPOTENT. Each campaign carries ref "iap:<id>". A retry after a
|
||||
// timeout returns the existing campaign instead of booking a second one.
|
||||
//
|
||||
// Capacity note (2026-09-18): that server serves roughly 5,500 popup impressions a day in
|
||||
// TOTAL, shared by every active campaign, and its rotator favours campaigns with the least
|
||||
// delivered. So syndicating everything would starve what is already running, including
|
||||
// Marty's own ads. Hence MAX_VIEWS_PER_CAMPAIGN and MIN_CREDITS below. Cost is not the
|
||||
// reason for the caps - Marty owns the platform and treats the placements as free - the
|
||||
// reason is that inventory there is finite and shared.
|
||||
'use strict';
|
||||
const https = require('https');
|
||||
const { URL } = require('url');
|
||||
|
||||
// Tier 1 only. These are the sole countries with a configured popup price on that server;
|
||||
// anything else is refused by the bridge rather than silently accepted.
|
||||
const TIER1 = ['US', 'CA', 'GB', 'AU', 'NZ'];
|
||||
const WORLDWIDE = ['all'];
|
||||
|
||||
const MIN_CREDITS = Number(process.env.ADREVLNKS_MIN_CREDITS || 1000); // below this, not worth a slot
|
||||
const MAX_VIEWS_PER_CAMPAIGN = Number(process.env.ADREVLNKS_MAX_VIEWS || 50000);
|
||||
const TRAFFIC_SOURCE = Number(process.env.ADREVLNKS_TRAFFIC_SOURCE || 1);
|
||||
const USER_ID = Number(process.env.ADREVLNKS_USER_ID || 2);
|
||||
|
||||
function enabled() {
|
||||
return !!(process.env.ADREVLNKS_BRIDGE_URL && process.env.ADREVLNKS_BRIDGE_KEY);
|
||||
}
|
||||
|
||||
function call(action, payload, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!enabled()) return reject(new Error('adrevlnks-disabled'));
|
||||
const u = new URL(process.env.ADREVLNKS_BRIDGE_URL);
|
||||
const body = JSON.stringify(Object.assign({ action }, payload || {}));
|
||||
const req = https.request({
|
||||
hostname: u.hostname, path: u.pathname + u.search, method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body),
|
||||
'X-Bridge-Key': process.env.ADREVLNKS_BRIDGE_KEY },
|
||||
timeout: timeoutMs || 25000
|
||||
}, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => {
|
||||
let j = null;
|
||||
try { j = JSON.parse(d); } catch (e) {}
|
||||
if (!j) return reject(new Error('adrevlnks ' + res.statusCode + ': ' + d.slice(0, 120)));
|
||||
if (j.error) return reject(new Error(j.error));
|
||||
resolve(j);
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => req.destroy(new Error('adrevlnks timeout')));
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
const refFor = campaignId => 'iap:' + Number(campaignId);
|
||||
|
||||
// How many popup views a campaign's credits are worth. 1 credit = 1 view, capped so a
|
||||
// single large campaign cannot swallow the whole server's daily inventory.
|
||||
function viewsFor(credits) {
|
||||
const v = Math.max(0, Math.floor(Number(credits) || 0));
|
||||
return Math.min(v, MAX_VIEWS_PER_CAMPAIGN);
|
||||
}
|
||||
|
||||
// Push a campaign. Returns null when it is not worth syndicating, rather than throwing,
|
||||
// so the caller can treat "too small" and "disabled" the same quiet way.
|
||||
async function push(campaign, opts) {
|
||||
if (!enabled()) return null;
|
||||
const credits = Number((campaign.budget || 0) - (campaign.spent || 0) - (campaign.accrued || 0));
|
||||
if (credits < MIN_CREDITS) return null;
|
||||
const views = viewsFor(credits);
|
||||
if (views < 1000) return null;
|
||||
|
||||
const title = String(campaign.name || campaign.title || 'InstantAdPay').slice(0, 250);
|
||||
const url = String(campaign.targetUrl || campaign.target_url || '').trim();
|
||||
if (!/^https?:\/\//i.test(url)) return null;
|
||||
|
||||
const countries = (opts && opts.worldwide) ? WORLDWIDE : TIER1;
|
||||
return await call('create_popup', {
|
||||
title, url, views, countries,
|
||||
traffic_source: TRAFFIC_SOURCE,
|
||||
user_id: USER_ID,
|
||||
ref: refFor(campaign.id),
|
||||
activate: !(opts && opts.paused)
|
||||
});
|
||||
}
|
||||
|
||||
// Real delivery, straight from the rotator's counters. Never derived.
|
||||
async function readServed(campaignId) {
|
||||
if (!enabled()) return null;
|
||||
try { return await call('status', { ref: refFor(campaignId) }); }
|
||||
catch (e) { if (/not found/i.test(e.message)) return null; throw e; }
|
||||
}
|
||||
|
||||
async function pause(campaignId) { return enabled() ? call('pause', { ref: refFor(campaignId) }) : null; }
|
||||
async function resume(campaignId) { return enabled() ? call('resume', { ref: refFor(campaignId) }) : null; }
|
||||
async function remove(campaignId) {
|
||||
if (!enabled()) return null;
|
||||
try { return await call('delete', { ref: refFor(campaignId) }); }
|
||||
catch (e) { if (/not found/i.test(e.message)) return null; throw e; }
|
||||
}
|
||||
|
||||
module.exports = { enabled, push, readServed, pause, resume, remove, viewsFor, refFor,
|
||||
TIER1, WORLDWIDE, MIN_CREDITS, MAX_VIEWS_PER_CAMPAIGN };
|
||||
@@ -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('⚠️ InstantAdPay 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 };
|
||||
@@ -14,7 +14,13 @@ let IS_PROD = false;
|
||||
let SITE = 'instantadpay.com';
|
||||
|
||||
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
|
||||
// 4 hours (Marty, 2026-09-18). Two reasons, and we say both of them out loud rather than
|
||||
// letting it feel like a glitch: it keeps resources tidy, and it is what makes the login-ad
|
||||
// slot worth buying — an advertiser paying for a 30-day run needs members to actually pass
|
||||
// the gate more than once a day. Members are told the length up front, on the sign-in screen,
|
||||
// in the training and by the chatbot, so nobody is surprised by it.
|
||||
const SESSION_TTL = Number(process.env.SESSION_TTL_HOURS || 4) * 60 * 60 * 1000;
|
||||
const SESSION_HOURS = SESSION_TTL / 3600000;
|
||||
const challenges = new Map(); // addressLower -> {message, exp}
|
||||
|
||||
// ---- JSON session fallback ----
|
||||
@@ -146,7 +152,15 @@ async function fromRequest(req) {
|
||||
return impl().get(decodeURIComponent(m[1]));
|
||||
}
|
||||
async function refreshMemberId(sess) {
|
||||
if (!sess || !sess.address) return (sess && sess.memberId) || 0;
|
||||
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 });
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Achievement badge image, rendered on the server with ffmpeg (Marty, 2026-09-16).
|
||||
// The browser used to compose it on a canvas and upload the JPEG; a phone that ran short of canvas
|
||||
// memory (cryptomonk's Spark post) sent a mostly blank card to the group. Now the server draws the
|
||||
// member's name onto the badge art itself, the same way the Video Maker draws end cards, so every
|
||||
// badge looks the same no matter what device unlocked it. The browser upload stays as a fallback.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
let PUBLIC_DIR = null;
|
||||
const ART = { payouts: { file: 'badge-spark.jpg', ribbonY: 0.728 }, firstBuyer: { file: 'badge-surge.jpg', ribbonY: 0.76 }, level2: { file: 'badge-circuit.jpg', ribbonY: 0.72 }, level3: { file: 'badge-nexus.jpg', ribbonY: 0.73 }, fridays: { file: 'badge-fridays.jpg', ribbonY: 0.719 } };
|
||||
let FONT;
|
||||
function font() {
|
||||
if (FONT !== undefined) return FONT;
|
||||
FONT = process.env.BADGE_FONT || null;
|
||||
if (FONT) return FONT;
|
||||
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 (/DejaVuSans-Bold\.ttf$/i.test(f)) return f; } return null; };
|
||||
for (const d of ['/usr/share/fonts', '/usr/local/share/fonts']) { const r = walk(d); if (r) { FONT = r; break; } }
|
||||
if (!FONT && process.platform === 'win32' && fs.existsSync('C:/Windows/Fonts/arialbd.ttf')) FONT = 'C:/Windows/Fonts/arialbd.ttf';
|
||||
return FONT;
|
||||
}
|
||||
const ffText = t => String(t).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/:/g, '\\:').replace(/%/g, '%%');
|
||||
function init(opts) { PUBLIC_DIR = opts.publicDir; }
|
||||
// returns a JPEG buffer, or null when ffmpeg or the font is missing (caller falls back to the upload)
|
||||
function render(key, who) {
|
||||
return new Promise(resolve => {
|
||||
const a = ART[key]; const F = font(); if (!a || !F) return resolve(null);
|
||||
const src = path.resolve(PUBLIC_DIR, 'badges', a.file); if (!fs.existsSync(src)) return resolve(null);
|
||||
const name = String(who || '').slice(0, 28);
|
||||
// a Windows font path carries a drive colon that the filter parser trips on: run from the font's folder instead
|
||||
const winPath = /:/.test(F);
|
||||
const fontFile = winPath ? path.basename(F) : F;
|
||||
// 1080-wide art: 59px bold gold text with a dark outline, centred on the ribbon (matches the old canvas layout)
|
||||
const vf = name ? 'drawtext=fontfile=' + fontFile + ":text='" + ffText(name) + "':fontcolor=#ffd15c:fontsize=59:borderw=7:bordercolor=0x04140f@0.9:x=(w-text_w)/2:y=" + a.ribbonY + '*h-text_h/2' : 'null';
|
||||
const p = spawn('ffmpeg', ['-v', 'error', '-i', src, '-vf', vf, '-frames:v', '1', '-q:v', '3', '-f', 'image2pipe', '-vcodec', 'mjpeg', 'pipe:1'], { stdio: ['ignore', 'pipe', 'pipe'], cwd: winPath ? path.dirname(F) : undefined });
|
||||
const chunks = []; let err = '';
|
||||
p.stdout.on('data', c => chunks.push(c)); p.stderr.on('data', c => { err += c; });
|
||||
p.on('error', () => resolve(null));
|
||||
p.on('close', code => { const buf = Buffer.concat(chunks); if (code === 0 && buf.length > 2000 && buf[0] === 0xff && buf[1] === 0xd8) resolve(buf); else { console.error('badge render failed', code, err.slice(0, 200)); resolve(null); } });
|
||||
});
|
||||
}
|
||||
module.exports = { init, render, font, ART };
|
||||
@@ -0,0 +1,162 @@
|
||||
// Public blog (Marty, 2026-09-12): coaching and teaching articles on instantadpay.com, 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/<slug>, /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://instantadpay.com';
|
||||
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(/<!--[\s\S]*?-->/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 '</' + tag + '>';
|
||||
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 '<a href="' + href.replace(/"/g, '%22') + '"' + (internal ? '' : ' target="_blank" rel="noopener"') + '>';
|
||||
}
|
||||
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) ? '<img src="' + s.replace(/"/g, '%22') + '" alt="' + esc(alt) + '" loading="lazy">' : '';
|
||||
}
|
||||
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 '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
+ '<title>' + esc(o.title) + '</title><meta name="description" content="' + esc(o.desc) + '"><link rel="canonical" href="' + esc(url) + '">'
|
||||
+ '<meta name="robots" content="index,follow,max-image-preview:large"><meta name="theme-color" content="#043b2f">'
|
||||
+ '<meta property="og:type" content="' + (o.article ? 'article' : 'website') + '"><meta property="og:site_name" content="InstantAdPay"><meta property="og:title" content="' + esc(o.title) + '"><meta property="og:description" content="' + esc(o.desc) + '"><meta property="og:url" content="' + esc(url) + '"><meta property="og:image" content="' + esc(img) + '">'
|
||||
+ (o.article ? '<meta property="article:published_time" content="' + new Date(o.article.publishedAt || o.article.created).toISOString() + '"><meta property="article:modified_time" content="' + new Date(o.article.updated).toISOString() + '"><meta property="article:author" content="' + esc(AUTHOR) + '">' + (o.article.tags || []).map(t => '<meta property="article:tag" content="' + esc(t) + '">').join('') : '')
|
||||
+ '<meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="' + esc(o.title) + '"><meta name="twitter:description" content="' + esc(o.desc) + '"><meta name="twitter:image" content="' + esc(img) + '">'
|
||||
+ '<link rel="alternate" type="application/rss+xml" title="InstantAdPay blog" href="' + SITE + '/blog/feed.xml"><link rel="icon" type="image/png" href="/logo-icon.png">'
|
||||
+ '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"><link rel="stylesheet" href="/assets/site.css?v=20260912b">'
|
||||
+ '<style>.bl{max-width:760px}.bl h1{font-size:clamp(30px,4.6vw,44px);line-height:1.12;margin:10px 0 12px}.bl .meta{color:var(--muted);font-size:14px;margin:0 0 22px}.bl .cover{width:100%;border-radius:14px;border:1px solid var(--line);margin:0 0 26px;display:block}.bl article{font-size:17.5px;line-height:1.7}.bl article p{margin:0 0 18px;max-width:68ch}.bl article h2{font-size:26px;margin:36px 0 12px}.bl article h3{font-size:20px;margin:28px 0 10px}.bl article ul,.bl article ol{margin:0 0 18px 22px;max-width:66ch}.bl article li{margin:6px 0}.bl article blockquote{border-left:4px solid var(--mint);margin:0 0 18px;padding:8px 18px;color:var(--muted);font-style:italic}.bl article img{max-width:100%;border-radius:12px;border:1px solid var(--line)}.bl article a{color:var(--mint)}.bl article pre{background:rgba(4,8,7,.6);border:1px solid var(--line);border-radius:10px;padding:14px;overflow:auto;font-size:14px}.bl article table{border-collapse:collapse;width:100%;font-size:15px}.bl article th,.bl article td{border-bottom:1px solid var(--line);padding:8px 10px;text-align:left}.tags a{display:inline-block;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--mint);border:1px solid rgba(67,232,195,.4);border-radius:999px;padding:3px 10px;margin:0 6px 6px 0;text-decoration:none}.post-card{display:block;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin:0 0 14px;color:inherit;text-decoration:none}.post-card:hover{border-color:var(--mint)}.post-card h2{font-size:22px;margin:0 0 6px}.post-card p{margin:0;color:var(--muted);font-size:15px;max-width:70ch}.post-card .meta{margin:8px 0 0;font-size:13px}.author{display:flex;gap:14px;align-items:center;border:1px solid var(--line);border-radius:14px;padding:16px 18px;margin:36px 0 0;background:var(--panel)}.author img{width:56px;height:56px;border-radius:50%;object-fit:cover}.author b{display:block}.author span{color:var(--muted);font-size:14px}.share a{margin-right:14px;font-size:14px}.pager{display:flex;justify-content:space-between;margin:26px 0}.related{margin-top:40px}.related h3{margin-bottom:10px}</style>'
|
||||
+ '</head><body><div class="wrap bl">';
|
||||
}
|
||||
function tail() {
|
||||
return '</div><script src="/assets/common.js?v=20260914a"></script><script src="/assets/blog-page.js?v=20260921a"></script></body></html>';
|
||||
}
|
||||
const authorBox = () => '<div class="author"><img src="/logo-icon.png" alt="InstantAdPay"><div><b>' + AUTHOR + '</b><span>Founder of InstantAdPay and the Crypto Team Build Network. Twenty-plus years of internet marketing, and a habit of writing down what actually worked.</span></div></div>';
|
||||
const cardHtml = p => '<a class="post-card" href="/blog/' + esc(p.slug) + '">' + (p.cover ? '<img src="' + esc(p.cover) + '" alt="" loading="lazy" style="width:100%;border-radius:10px;margin:0 0 12px">' : '') + '<h2>' + esc(p.title) + '</h2><p>' + esc(p.excerpt) + '</p><p class="meta">' + fmtDate(p.publishedAt || p.created) + ' · ' + readMinutes(p.body) + ' min read' + (p.tags.length ? ' · ' + p.tags.map(esc).join(', ') : '') + '</p></a>';
|
||||
|
||||
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 | InstantAdPay';
|
||||
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 += '<section class="hero" style="padding:56px 0 8px"><p class="eyebrow">' + (tag ? 'Tag: ' + esc(tag) : 'The InstantAdPay blog') + '</p><h1>Notes on building a line, <em>one honest day at a time</em>.</h1><p class="lead">' + esc(desc) + '</p></section>';
|
||||
h += slice.length ? slice.map(cardHtml).join('') : '<p class="muted">Nothing published yet. Check back soon.</p>';
|
||||
if (pages > 1) h += '<div class="pager">' + (page > 1 ? '<a href="/blog' + (page - 1 > 1 ? '/page/' + (page - 1) : '') + '">← Newer</a>' : '<span></span>') + (page < pages ? '<a href="/blog/page/' + (page + 1) + '">Older →</a>' : '<span></span>') + '</div>';
|
||||
h += '<script type="application/ld+json">' + JSON.stringify({ '@context': 'https://schema.org', '@type': 'Blog', name: 'InstantAdPay blog', url: SITE + '/blog', publisher: { '@type': 'Organization', name: 'InstantAdPay', logo: SITE + '/logo.png' } }) + '</script>';
|
||||
return h + tail();
|
||||
}
|
||||
function renderPost(p, related) {
|
||||
const url = SITE + '/blog/' + p.slug;
|
||||
let h = head({ title: p.title + ' | InstantAdPay', desc: p.excerpt, path: '/blog/' + p.slug, image: p.cover, article: p });
|
||||
h += '<p class="eyebrow" style="margin-top:44px"><a href="/blog" style="color:var(--mint);text-decoration:none">Blog</a>' + (p.tags[0] ? ' · <a href="/blog/tag/' + encodeURIComponent(p.tags[0]) + '" style="color:var(--mint);text-decoration:none">' + esc(p.tags[0]) + '</a>' : '') + '</p>';
|
||||
h += '<h1>' + esc(p.title) + '</h1><p class="meta">By ' + esc(AUTHOR) + ' · ' + fmtDate(p.publishedAt || p.created) + ' · ' + readMinutes(p.body) + ' min read</p>';
|
||||
if (p.cover) h += '<img class="cover" src="' + esc(p.cover) + '" alt="' + esc(p.title) + '">';
|
||||
h += '<article>' + p.body + '</article>';
|
||||
if (p.tags.length) h += '<p class="tags" style="margin-top:22px">' + p.tags.map(t => '<a href="/blog/tag/' + encodeURIComponent(t) + '">' + esc(t) + '</a>').join('') + '</p>';
|
||||
h += '<p class="share" style="margin-top:18px"><a href="https://x.com/intent/tweet?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent(p.title) + '" target="_blank" rel="noopener">Share on X</a><a href="https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(url) + '" target="_blank" rel="noopener">Share on Facebook</a><a href="https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent(p.title) + '" target="_blank" rel="noopener">Telegram</a></p>';
|
||||
h += authorBox();
|
||||
if (related.length) h += '<div class="related"><h3>Keep reading</h3>' + related.map(cardHtml).join('') + '</div>';
|
||||
h += '<p class="muted small" style="margin-top:34px">InstantAdPay sells advertising. Nothing here is investment advice, no income is guaranteed, and cryptocurrency involves risk of loss.</p>';
|
||||
h += '<script type="application/ld+json">' + JSON.stringify({ '@context': 'https://schema.org', '@type': 'BlogPosting', headline: p.title, description: p.excerpt, image: p.cover ? (p.cover.startsWith('http') ? p.cover : SITE + p.cover) : SITE + '/banners/iap-hero-1200x630.png', datePublished: new Date(p.publishedAt || p.created).toISOString(), dateModified: new Date(p.updated).toISOString(), wordCount: words(p.body).split(' ').length, keywords: p.tags.join(', '), author: { '@type': 'Person', name: AUTHOR, url: SITE + '/blog' }, publisher: { '@type': 'Organization', name: 'InstantAdPay', logo: { '@type': 'ImageObject', url: SITE + '/logo.png' } }, mainEntityOfPage: { '@type': 'WebPage', '@id': url } }) + '</script>';
|
||||
h += '<script type="application/ld+json">' + JSON.stringify({ '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: [{ '@type': 'ListItem', position: 1, name: 'Blog', item: SITE + '/blog' }, { '@type': 'ListItem', position: 2, name: p.title, item: url }] }) + '</script>';
|
||||
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 => '<item><title>' + esc(p.title) + '</title><link>' + SITE + '/blog/' + p.slug + '</link><guid>' + SITE + '/blog/' + p.slug + '</guid><pubDate>' + new Date(p.publishedAt || p.created).toUTCString() + '</pubDate><description>' + esc(p.excerpt) + '</description></item>').join('');
|
||||
return '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>InstantAdPay blog</title><link>' + SITE + '/blog</link><description>Coaching and teaching articles from Marty Bostick.</description>' + items + '</channel></rss>';
|
||||
}
|
||||
function sitemap(posts) {
|
||||
const pages = ['/', '/blog', '/whats-new', '/leaderboard', '/ledger', '/contract', '/plays', '/wallets', '/earning', '/partners', '/friday'];
|
||||
const u = pages.map(p => '<url><loc>' + SITE + p + '</loc><changefreq>weekly</changefreq></url>').join('')
|
||||
+ posts.map(p => '<url><loc>' + SITE + '/blog/' + p.slug + '</loc><lastmod>' + new Date(p.updated).toISOString().slice(0, 10) + '</lastmod><changefreq>monthly</changefreq></url>').join('');
|
||||
return '<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' + u + '</urlset>';
|
||||
}
|
||||
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 };
|
||||
@@ -4,8 +4,24 @@
|
||||
// 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;
|
||||
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;
|
||||
@@ -37,6 +53,30 @@ async function setup() {
|
||||
} 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 };
|
||||
@@ -53,15 +93,33 @@ async function tick() {
|
||||
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.ref)); }
|
||||
catch (e) { state.skipped[b.id] = String(e.reason || e.shortMessage || e.message).slice(0, 120); continue; }
|
||||
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) };
|
||||
const tx = await ctr.consume(Number(b.memberId), 0, BigInt(b.amount), refToBytes32(b.ref), overrides);
|
||||
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); burned += 1; state.burned += 1; state.lastTx = tx.hash; state.lastError = null; }
|
||||
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);
|
||||
@@ -76,5 +134,5 @@ async function tick() {
|
||||
return { burned };
|
||||
}
|
||||
function status() { return Object.assign({}, state, { hasEthers: !!ethers, keyPresent: !!keyHex() }); }
|
||||
function init(opts) { chain = opts.chain; ads = opts.ads; setup().catch(e => { state.lastError = e.message; }); }
|
||||
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 };
|
||||
|
||||
@@ -46,11 +46,16 @@ const SEL = {
|
||||
|
||||
const CHUNK = 9000;
|
||||
const POLL_MS = 30000;
|
||||
const KEEP_EVENTS = 600;
|
||||
// 2026-09-16: was 600. Eleven consumers (member Earnings page, dashboard earned total, leaderboard,
|
||||
// holding-tank own-buy check, admin member view, growth snapshot, P&L) read this list as if it
|
||||
// were the whole history; once the contract passed 600 events (Sept 12) every older member's
|
||||
// purchases and payouts silently vanished from all of them. Keep the full history; rescan() rebuilds it.
|
||||
const KEEP_EVENTS = 250000;
|
||||
|
||||
let cfg = null;
|
||||
let state = null;
|
||||
let busy = false;
|
||||
let dirty = false;
|
||||
let onEvent = null;
|
||||
|
||||
function getConfig() {
|
||||
@@ -229,11 +234,48 @@ async function tail() {
|
||||
}
|
||||
if (state.events.length > KEEP_EVENTS) state.events = state.events.slice(-KEEP_EVENTS);
|
||||
state.lastBlock = to;
|
||||
dirty = true;
|
||||
}
|
||||
saveState();
|
||||
if (dirty) { saveState(); dirty = false; }
|
||||
} catch (e) { console.error('chain tail', e.message); }
|
||||
busy = false;
|
||||
}
|
||||
// Full history rebuild from the deploy block. Does NOT fire onEvent (no repeat Telegram/email
|
||||
// posts) and keeps the real indexed-at ts of events already known; backfilled events get a ts
|
||||
// estimated from their block height so day/week windows stay honest enough.
|
||||
async function rescan() {
|
||||
let waited = 0; while (busy && waited < 120000) { await new Promise(r => setTimeout(r, 500)); waited += 500; }
|
||||
if (busy) return { error: 'indexer busy' };
|
||||
busy = true;
|
||||
try {
|
||||
const c = getConfig();
|
||||
const tip = await bestTip(); const latest = tip.block;
|
||||
const known = new Map(state.events.map(e => [e.tx + ':' + e.li, e]));
|
||||
const oldest = state.events.find(e => e.ts && e.block);
|
||||
const spb = oldest && latest > oldest.block ? Math.max(1000, Math.min(4000, (Date.now() - oldest.ts) / (latest - oldest.block))) : 2150; // ms per block
|
||||
const evs = [];
|
||||
for (let from = c.deployBlock; from <= latest; from += CHUNK) {
|
||||
const to = Math.min(from + CHUNK - 1, latest);
|
||||
const range = [{ address: c.contract, fromBlock: '0x' + from.toString(16), toBlock: '0x' + to.toString(16) }];
|
||||
let logs;
|
||||
try { logs = await rpcOnce(tip.url, 'eth_getLogs', range); } catch (e) { logs = await rpc('eth_getLogs', range); }
|
||||
for (const lg of logs) {
|
||||
const ev = decodeLog(lg); if (!ev) continue;
|
||||
const old = known.get(ev.tx + ':' + ev.li);
|
||||
ev.ts = old && old.ts ? old.ts : Math.round(Date.now() - (latest - ev.block) * spb);
|
||||
evs.push(ev);
|
||||
}
|
||||
}
|
||||
state.events = evs.slice(-KEEP_EVENTS);
|
||||
state.lastBlock = latest;
|
||||
state.totals = null; for (const ev of state.events) tally(ev);
|
||||
state.full = true;
|
||||
saveState();
|
||||
console.log('chain rescan: ' + evs.length + ' events, blocks ' + c.deployBlock + '-' + latest);
|
||||
return { ok: true, events: evs.length, fromBlock: c.deployBlock, toBlock: latest };
|
||||
} catch (e) { console.error('chain rescan', e.message); return { error: e.message }; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
function recentEvents(n) { return state ? state.events.slice(-(n || 100)).reverse() : []; }
|
||||
|
||||
// running totals for the public counters — every number provable on-chain
|
||||
@@ -254,6 +296,9 @@ function init(opts) {
|
||||
getConfig(); loadState();
|
||||
tail();
|
||||
setInterval(tail, POLL_MS);
|
||||
// one-time migration: a state that filled the old 600-event window has lost history; rebuild it.
|
||||
// (Fresh/test servers have an empty state and never trigger this.)
|
||||
if (!state.full && state.events.length >= 600) setTimeout(() => { rescan().catch(() => {}); }, 8000);
|
||||
}
|
||||
|
||||
// Recommended EIP-1559 fees straight from the network. Polygon Amoy's Bor nodes
|
||||
@@ -273,4 +318,5 @@ async function suggestedFees() {
|
||||
}
|
||||
|
||||
module.exports = { init, getConfig, reloadConfig, memberIdByAccount, memberCount, member,
|
||||
product, productCount, quoteWei, creditBalance, catalog, recentEvents, totals, rpc, decodeLog, suggestedFees };
|
||||
product, productCount, quoteWei, creditBalance, catalog, recentEvents, totals, rpc, decodeLog, suggestedFees, rescan,
|
||||
eventCount: () => (state ? state.events.length : 0), historyComplete: () => !!(state && state.full) };
|
||||
|
||||
@@ -20,20 +20,54 @@ function key() {
|
||||
function enabled() { return !!key(); }
|
||||
|
||||
// ---- canned answers: instant, free, always in voice ----
|
||||
const KNOWLEDGE_DATE = '2026-09-18'; // bump whenever the canned answers / prompt are brought up to date
|
||||
const CANNED = [
|
||||
{ re: /(viral link|any page.{0,30}(link|referr)|blog.{0,40}(referral|my link|credit)|(referral|invite|my) link.{0,30}(blog|article|any page)|\?ref=|ref=)/i,
|
||||
a: 'Any page on instantadpay.com becomes your referral link when you add ?ref= and your username to the end, for example instantadpay.com/blog/what-is-a-feeder-program?ref=yourname. Whoever opens it is tagged to you for 30 days, exactly like your invite link, so if they join from any page on the site the credit is yours. Promo tools > Viral links builds these links for you, and every blog article shows your link when you are signed in.' },
|
||||
{ re: /(five dollar friday|5 dollar friday|\$5 friday|friday (bonus|promo|deal|special)|what happens on friday)/i,
|
||||
a: 'Five Dollar Friday: every Friday (Central time), any ad package from $5 up earns +20% bonus credits, added the moment the purchase settles and labelled in your credit log. The page instantadpay.com/friday shows whether it is live right now and the next date; add ?ref=yourusername to that address and anyone who joins from it is your referral. Every pack pays its sponsor line in the same on-chain transaction, so when the whole network buys on the same day every line gets paid at once. Five Fridays in a row earn the Five Fridays badge. Buy under My account > Buy packages.' },
|
||||
// 2026-09-18: sessions were shortened to 4h on purpose. Members WILL ask why they keep being
|
||||
// signed out, and the honest answer reads far better than letting it look like a fault.
|
||||
{ re: /(logged out|log(ged)? me out|signed (me |us )?out|keep.{0,20}(logging|signing) in|session (expired|timed? out)|why.{0,25}(log ?in|sign ?in) again|have to log ?in again|stay logged in)/i,
|
||||
a: 'That is deliberate, not a fault. To keep resources optimised we do not keep anyone signed in longer than 4 hours. After that your session ends and you sign in again with a fresh code - nothing about your account, your credits or your line is affected. You will see the login sponsor ad on the way back in, and that is part of what keeps the free side of the platform paid for.' },
|
||||
{ re: /(second account|another account|two accounts|multiple accounts|more than one account|duplicate account|new account with|refer myself|self.?refer|sign up (my )?(wife|husband|partner|spouse|kids?|family))/i,
|
||||
a: 'One account per person, and that is in the Terms (https://instantadpay.com/terms). A second account under another email, name or wallet is not allowed, and neither is referring yourself through one. The one sanctioned way to hold more than one position is Qualified Start: extra wallets linked inside your single account, on the Buy packages tab, where they are visible as yours. A family member who genuinely wants to join can join under your link with their own email. Duplicate accounts can be merged or closed and any credits or prizes earned through them are forfeited; payments the contract already made cannot be reversed.' },
|
||||
{ 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://instantadpay.com/contract and https://instantadpay.com/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.' },
|
||||
// "how much do I earn" used to fall through to the PRICE answer below, because that one
|
||||
// matches "how much do" and this one only caught "how much earn" (Marty, 2026-09-17).
|
||||
{ re: /(how (much|do i) earn|how much (do|can|will|would|could|might) (i|we|you|they) (earn|make|get)|what (do|would|will) i (earn|make|get paid)|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. The package ladder is on the home page. 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. Two formats are flat buys paid in full when booked: featured links (sold by the day) and verified-visit packs. Adding credits to a featured link does nothing, so its row offers Extend run instead, which books more days at the daily rate; a visit pack offers Buy more visits. Every credit that comes in or goes out is listed with its reason under Members > Campaigns > Credit activity (also linked from Earn credits): sign-in bonus, daily claim, video watched, verified visit, campaign buys, views delivered per day, grants and refunds. Check it before assuming credits went missing.' },
|
||||
{ re: /(why (didn'?t|did not|hasn'?t|has not) (my sponsor|i|he|she|they|.{1,30}) (get|got|been) paid|didn'?t get paid|not get paid|missed (a |the )?(payment|commission)|trace (a )?payment|payment trace|who got paid)/i,
|
||||
a: 'Every purchase and every share is on the chain, and you can read it yourself: open Members > Earnings > Trace a payment, enter the member number or username (yourself, anyone up to 3 levels below you, or your own uplines), and each purchase they were part of is laid out level by level: who was paid, who was skipped and why. The three usual answers: the person had not unlocked that level when the purchase settled (level 2 needs 2 qualifying $20+ buyers, level 3 needs 5, and qualification never applies backwards); the buyer used Qualified Start, so the purchase came from a linked wallet whose sponsor is the buyer\'s own main position, which means level 1 went to the buyer themselves; or the person\'s payouts were not switched on. Skipped shares pass up to the next qualified person, never to nobody.' },
|
||||
{ re: /(sponsor (never|hasn'?t|has not|didn'?t|did not|won'?t|hasnt|didnt) (switch|turn|activat|link)|(payouts?|wallet) (not |aren'?t |isn'?t |never )?(switched|turned|set) on|unactivated sponsor|not activated sponsor|routed past|walk(ed|s)? up|goes? to the company|company position)/i,
|
||||
a: 'Your purchase never waits on your sponsor. If the person who referred you has not linked a wallet and switched on payouts when you buy, the site walks up your sponsor line to the first person who is activated with payouts on and the purchase goes through under them; if nobody in the line is ready it goes under the company position. You see a one-line note on the Buy tab and carry on. For the sponsor it is permanent: the contract locks a buyer to whoever was paid at the first purchase, so that referral moves to the upline who was paid and they cannot earn from that wallet later. They get a message saying so, and the upline gets one too. Two minutes on the Wallet tab prevents it.' },
|
||||
// PIF + the holding tank. Asked together often ("should I pif someone from the tank"),
|
||||
// and the honest answer is that the order matters: you cannot PIF someone still in the
|
||||
// tank. Canned so it never depends on the AI provider being up (Marty, 2026-09-17).
|
||||
// The second alternation catches the same question asked without the word PIF
|
||||
// ("should I fund their first package", "can I buy it for them"). It needs BOTH a
|
||||
// paying verb AND an explicit for-someone-else phrase, so ordinary pricing questions
|
||||
// like "which package should I buy" are not stolen (Marty, 2026-09-17).
|
||||
{ re: /\b(pif|p\.i\.f|pay(ing)? it forward|paid it forward|gift (them|him|her|someone|a member)|send (them|him|her) (some )?pol)\b|\b(fund(ing)?|pay(ing)? for|cover(ing)?|buy(ing)?|purchas(e|ing)|front(ing)?|spot)\b[^.?!]{0,50}\b(for (them|him|her|someone|a member|a new member|my (referral|downline|member))|(their|his|her|someone'?s|a member'?s) (first )?(package|starter|activation|\$?20))\b|\b(front|spot|lend|loan|advance|stake|grubstake)\b[^.?!]{0,30}\b(them|him|her|someone|a member|a new member|my (referral|downline|member))\b/i,
|
||||
a: 'PIF is a gift you send from your own wallet, and the site never touches the money. It only appears on your own free referrals and on members you have adopted, and only once they have linked a wallet, so you cannot PIF somebody who is still sitting in the holding tank. The order is: adopt them from Members > My line > Holding tank, get them to link a wallet on their Wallet tab, then the PIF button shows up, you enter an amount and your own wallet app opens with their address already filled in. Now the part that matters most: do not send anything until you have actually talked to that person and they have told you they will use it. Once that POL leaves your wallet it is gone, there is no refund, no chargeback and nothing we can do to pull it back, because the money goes straight from your wallet to theirs and never touches us. The gift is theirs to keep whatever they do with it, so they can go quiet, spend it on something else, or never sign in again, and none of that is against any rule. Someone in the holding tank joined with nobody working with them and may never have engaged at all, which makes an unanswered PIF the easiest money on this site to lose. Get a reply, get a clear commitment that they are going to buy and build, see that they have linked a wallet and are actually active, and only then decide. If they do buy a $20 package the contract pays you 50 percent as their sponsor and they become one of your qualifying buyers, so a gift that works costs you about half; a gift to somebody who never answers costs you all of it. Never gift money you need, never borrow to do it, and treat every PIF as money you are content to lose.' },
|
||||
{ re: /(holding tank|adopt(ing|ion|ed)? (a |an |from|someone|member)|orphan(ed)? member|no sponsor|unsponsored|release to tank)/i,
|
||||
a: 'The holding tank is where free members who joined without a sponsor wait, and anyone can be picked up first come. To adopt you need payouts switched on and your own $20 or larger package bought; then open Members > My line > Holding tank. You can hold 2 open adoptions at a time and each one runs for 7 days, and if that person never links a wallet or buys they drop back into the tank. Adopting sets you as their sponsor, opens a chat and emails them, and their first purchase binds to you on the chain. You can also release one of your own free referrals into the tank for somebody else to work with, though not one you adopted less than 3 days ago. A member can be adopted twice in their lifetime at most, so an adoption you drop still costs them one of those chances.' },
|
||||
{ re: /(qualif|unlock level|level 2|level 3|pass.?up)/i,
|
||||
a: 'Level 1 is open to every member. 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.' },
|
||||
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://instantadpay.com/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. 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: '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: /(banner (image|url|link|size|creative|file)|image (url|link|field)|broken (banner|image)|upload (a |my |the )?(banner|image|creative)|(png|jpg|jpeg) link|which sizes?)/i,
|
||||
a: 'The image field on a banner campaign needs a direct link to the picture itself, one that ends in .png, .jpg, .gif, .webp or .svg, or an upload. A web page link (your join page, an image-hosting page) is refused, because it would show a broken banner. Easiest source: Promo tools > Banners, pick the size you want and tap Copy image URL. Sizes are the standard ad sizes (728x90, 300x250, 160x600, 320x50 and more); the campaign form asks for the size too. Banner and text ads also run out on the wider network.' },
|
||||
{ re: /(promo tools?|social posts?|email swipes?|banner kit|download banners?|promo 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.' },
|
||||
@@ -41,16 +75,26 @@ const CANNED = [
|
||||
a: 'Your wall (instantadpay.com/wall/yourname) shows three ads. Position 1 is always your line banner. Positions 2 and 3 show your upline (or InstantAdPay) 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.' },
|
||||
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 (5 on day one, 7 on day two, 10 from day three, 25 on every 7th day in a row), then keep going with videos (up to 8 a day), verified visits (up to 20 a day) and inbox reads (up to 5 rewarded a day). A separate sign-in bonus pays 5 credits on your first sign-in each day, rising by one per consecutive day up to 10. Earned credits fund every campaign format except login ads. You never see your own ads, and viewer rewards are credits, never cash.' },
|
||||
{ re: /(featured (link|strip|ad|run)|extend run|book (a )?featured)/i,
|
||||
a: 'Featured links run in the strip at the top of every member\'s Overview. Book one under Campaigns for 1, 2 or 7 days at 40 credits a day, up to 7 days ahead; at most 10 links share any day. It is a flat buy, paid in full when booked, and the row shows days left and "runs through" a date. To keep it going use Extend run on the row, which books more days at the same rate; adding credits does not add days. Views (one per viewer per hour) and clicks are counted on the row.' },
|
||||
{ re: /(verified visit|visit pack|buy visits|visits campaign)/i,
|
||||
a: 'A verified-visit pack sends real members to your site: each one opens it in a new tab, stays at least 8 seconds and passes a check, and no member counts twice. It costs 3 credits per visit, minimum 20 visits, paid in full when you create it, then it fills over time (each member can do at most 20 visits a day). The row shows "N of M visits delivered" and Buy more visits adds to the pack. Members earn 1 credit per verified visit.' },
|
||||
{ re: /(overview video|four.minute|4.minute|what is (this|instantadpay)|explain (the )?platform|how does (this|it) work)/i,
|
||||
a: 'The quickest answer is the four-minute overview video: it is the first card on Members > Training and on the home page at https://instantadpay.com/#overview-video. It covers what the platform is, what free members get, the campaign formats, how the contract splits every purchase, wallet and payouts, Qualified Start, the payment trace and the tools for building a line.' },
|
||||
{ re: /(\b(price|prices|pricing|costs?)\b|how much (is|are|does|do)|package (price|cost|size)|which package|what packages|what do i get|what am i buying|should i (buy|get|start with|go with)|worth (buying|getting|it to buy))/i,
|
||||
a: 'Five packages, priced in dollars and settled in POL at the live rate when you buy: Micro $5 for 500 credits, Activation $20 for 2,000, Builder $50 for 5,500, Growth $100 for 12,000, Leader $250 for 32,500. One credit is one cent of ad delivery in any format. Packages of $20 or more count toward qualification, and every purchase is split by the contract in the same transaction: 50 percent to your sponsor, 20 to level 2, 10 to level 3, 20 to the platform. The live POL prices are on the home page and the Buy packages tab.' },
|
||||
{ re: /(what (is|are) (a |an )?(ad )?credits?|credits? (worth|value|mean)|one credit|impression|cpm)/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 and text ads (also syndicated to the wider network), full-screen login ads, watch-to-earn video ads, solo ads into member inboxes, featured links in the strip on every Overview, and verified-visit packs, all managed from Members > Campaigns. Only your campaigns can spend your credits.' },
|
||||
{ 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://instantadpay.com/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://instantadpay.com/contract.' }
|
||||
];
|
||||
@@ -60,15 +104,61 @@ function systemPrompt() {
|
||||
return `You are the assistant on InstantAdPay (https://instantadpay.com), a membership advertising platform.
|
||||
|
||||
FACTS:
|
||||
- FIVE DOLLAR FRIDAY: every Friday, Central time, any ad package of $5 or more earns +20% bonus credits, credited the moment the purchase settles (credit log shows the label). First one: Friday 2026-09-25. Five Fridays in a row = the Five Fridays badge. The point: the whole network buys on the same day, every pack pays its sponsor line in the same transaction, so every line gets paid at once. No income promises.
|
||||
- SESSIONS LAST 4 HOURS. Sign-ins expire after 4 hours and the member signs in again with a fresh 6-digit code. This is intentional and we say so plainly: it keeps resources optimised, and the login sponsor ad shown on the way back in is part of what pays for the free side. Nothing is lost on expiry - credits, line and account are untouched. Never describe it as a bug, an error, or something to report.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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 on the Overview's coaching card show 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 with five hook videos, one per angle (Paid before the page reloads, You were buying traffic anyway, Watch first spend never, No back office, Two buyers open level two), each with a caption and the matched angle link, and at Circuit the Video Maker renders them with the member's own end card and QR; a Toolkit tab showing the badge ladder and the AI Copy Engine. 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 instantadpay.com/join/<username> 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/<username> — 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.
|
||||
- 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.
|
||||
- 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). Video watching is capped at 8 a day per member and pays 1, 2 or 4 credits for a 10, 30 or 60 second watch. FEATURED LINKS (live): a headline + link in the Featured strip at the top of every member's Overview, booked for 1, 2 or 7 days at 40 credits a day, up to 7 days ahead, at most 10 links sharing any day; flat buy paid when booked; the row shows days left and 'runs through <date>', counts views (one per viewer per hour) and clicks, and offers Extend run. VERIFIED-VISIT PACKS (live): 3 credits per visit, minimum 20 visits, paid up front; each visit is a distinct member who opens the site in a new tab, stays 8 seconds and passes a check, at most 20 visits per member per day; the row shows 'N of M visits delivered' and Buy more visits adds to the pack; the visitor earns 1 credit.
|
||||
- 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/<username>) 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.
|
||||
- FOUR-MINUTE OVERVIEW VIDEO (2026-09-16): 'InstantAdPay in four minutes' is the first card on Members > Training (group 'Start here') and embedded on the home page at instantadpay.com/#overview-video (hero button 'Watch the 4-minute overview'). Send it to anyone asking what the platform is; the tab-by-tab walkthrough videos on Training go deeper. It is evergreen: no dates, no launch talk.
|
||||
- ANTI-FRAUD CHECKS (2026-09-16): at sign-up the site records IP, browser type and a browser cookie id. A second account from a browser that already has one is refused, so is a burst of new accounts from one connection in a day, and so is a sign-up through an invite link whose owner used that same browser. A sign-up from the same IP as the sponsor is allowed but flagged (households are fine). Flagged accounts never count on the leaderboard and cannot adopt from the holding tank; a suspended account cannot sign in. If a member says they were refused at sign-up, or asks how to get a second account to qualify themselves: one account per person, sign in to the existing one, and use QUALIFIED START (Buy packages tab) to link extra wallets of their own as positions under that account; each linked wallet that buys $20+ counts as one of their qualifying buyers, credits pool, and half of each such purchase returns to their main wallet. Levels 2 and 3 open at 2 and 5 qualifying buyers whether those buyers are referrals or the member's own linked positions. Shared computer at work or a library: contact support. Never suggest a second account.
|
||||
- ONE ACCOUNT PER PERSON (Terms section 3, 2026-09-16): a person may hold exactly one account; second or duplicate accounts under any email, name or wallet, and self-referral through another account, are prohibited. The only sanctioned way to hold more than one position is Qualified Start (extra wallets linked inside the one account). Duplicates may be merged, suspended or closed and credits, prizes and contest rankings earned through them are forfeited; the contract's on-chain payments cannot be reversed. Family members join under the member's link with their own email.
|
||||
- BLOG (public, instantadpay.com/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 instantadpay.com/b/<username>/<badge>, 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/<username>), 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=<username>&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@instantadpay.com 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 (live since 2026-09-15): a follow-up board on the dashboard, tab 'Pipeline' between My line and Buy packages, with its own training video on the Training tab. 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.
|
||||
- UNACTIVATED SPONSOR = WALK UP, NEVER HOLD (2026-09-16, Marty): if a buyer's sponsor has not linked a wallet or switched on payouts, the purchase is NOT held. The site walks the sponsor line upward to the first upline who is activated with payouts on and the buy goes through under them (on-chain sponsorship for that buyer is then theirs, permanently); if nobody in the line is activated it goes under the company catch position (#1). The buyer sees a one-line notice and proceeds; once the position exists on the chain the skipped sponsor gets a blunt email + on-site message (they lost this referral for good, how to switch on payouts) and the upline who was paid gets a message that the referral is theirs permanently with a nudge to coach the one who missed it; the referral MOVES to whoever got paid (the account's stored sponsor is re-pointed when the position is created on-chain), so the buyer leaves the skipped sponsor's My line for good; the skipped sponsor can never earn from that wallet. Only a transient chain error ('rpc') still pauses a buy ("try again in a minute").
|
||||
- DRILL DOWN FROM THE OVERVIEW (2026-09-15): every name chip in 'Your line at a glance' on the Overview is clickable; it jumps to My line with that person's Activity panel already open and the row highlighted.
|
||||
- VISIT PACKS ARE PAID UP FRONT (2026-09-15, Michael's question): a verified-visits campaign charges the whole pack when it is created (3 credits per visit, so 1,000 visits = 3,000 credits) and then delivers over time as members complete verified visits (each member can do at most 20 a day, so packs fill gradually: tens per day at today's size). The Campaigns row shows 'paid up front' and 'N of M visits delivered'; the credits are not being consumed again, they were reserved once. Same for featured-day bookings: a featured link is a flat buy (featuredPerDay credits per day, whole run charged when booked), its row shows 'paid up front', the day count and 'runs through <date>', and it ends on its last day ('run ended'). FEATURED CANNOT TAKE ADDED CREDITS (2026-09-16, Hugh and Michael both tried): the row offers 'Extend run' which books more days after the current run (or from today if it ended) and charges them right away; there is no 'Buy more views' on a featured row any more. Visit packs offer 'Buy more visits' (charged now, adds to the pack). Credits added to a campaign are set aside immediately (Available drops at once) and whatever is unspent returns to Available when the campaign ends.
|
||||
- BANNER CREATIVE MUST BE AN IMAGE LINK (2026-09-16): the New campaign image field needs a direct image URL (ends in .png/.jpg/.gif/.webp/.svg) or an upload; a web-page link (a join page, an imgbb/ibb.co page) is refused with a message. Easiest source: Promo tools > Banners > Copy image URL. A campaign created with a page link before this check served a broken banner and was repaired by admin.
|
||||
- PAYMENT TRACE (2026-09-16): Members > Earnings > "Trace a payment": enter a member number or @username (yourself, anyone up to 3 levels below you, or your own 3 uplines) and every purchase they were part of is listed tier by tier — who was paid at level 1/2/3 (50/20/10%), who was skipped and why ("not qualified: had N of 2 qualifying buyers then"), the platform's 20%, and a verify link to the transaction. Use it to answer "why didn't my sponsor / why didn't I get paid on X's purchase" — the usual answers are (a) the sponsor had not unlocked that level when the purchase settled (qualification never applies backwards), (b) the buyer used Qualified Start, so the purchase came from a linked wallet whose on-chain sponsor is the buyer's own main position (level 1 goes to the buyer themselves), (c) the sponsor's payouts were not switched on. Admins see the same trace under the member card in Admin > Members.
|
||||
- CREDIT ACTIVITY LEDGER (2026-09-16): Members > Campaigns has a 'Credit activity' list (last 40 lines, newest first; also linked from the Earn credits pane) showing every real credit movement with a reason: welcome credits, sign-in bonus with streak, daily claim, inbox reads, video watches, verified visits, milestone and leaderboard bonuses, partner codes, admin/team grants, AI Copy Engine charges and refunds, flat buys (featured runs, extensions, visit packs, more visits), login-ad days, and metered delivery rolled up as one line per campaign per day ('Ad views: NAME', 'Inbox deliveries: NAME', 'Video views: NAME'); lines marked '(purchased credits)' spent on-chain purchased credits rather than the earned pool. Reservations (credits set aside for a live campaign) are NOT ledger lines; they show as 'in live campaigns'. Earned credits are spent before purchased ones, so an earn followed by a buy appears as two lines. The ledger starts 2026-09-16; nothing earlier is listed.
|
||||
- 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. MISSED PAYOUTS ARE ALSO PUBLIC (2026-09-16): the team's Telegram payments feed posts, by username, who was passed over on a purchase, why (not qualified yet with N of 2 buyers, or payouts not switched on), the amount, and who received it instead; the same for a sale that routed past an unactivated sponsor. Nothing about the contract changed; it is visibility.
|
||||
- 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 ARE A SLOT (2026-09-19): 1,000 purchased credits buys 30 days on the sign-in screen, paid up front, never metered. At most 3 member slots run at once because every slot shares the same sign-ins; when all 3 are taken the buy is refused until one ends. House login ads only show when no member slot is running. Featured links count views (one per viewer per hour) as well as clicks.
|
||||
- AD VIEWER OPEN-IN-TAB (2026-09-19): the earn viewer shows the advertiser's page inside a frame; a link in its bar opens the same site in a new tab for sites that refuse to load framed or where signing up is easier in a full tab. The view still credits from the frame's countdown and check.
|
||||
- (2026-09-14 hold rule REPLACED on 2026-09-16 by the walk-up rule above: a purchase is never held for an unactivated sponsor; only a transient chain read error pauses a buy.)
|
||||
- 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): instantadpay.com/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): instantadpay.com/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 instantadpay.com/from/faucetwave or instantadpay.com/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. SOCIALPIX GATE (2026-09-20): socialpix.club no longer takes ad posts; its visitors are sent to instantadpay.com/from/socialpix (advertiser copy by default, ?seg=earn for the earner copy). No welcome-back credits for SocialPix arrivals; they join free by email and land in the holding tank.
|
||||
- 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. You cannot PIF someone still in the holding tank: adopt them first, they link a wallet, then the button appears.
|
||||
- PIF RISK (Marty, 2026-09-17, say this EVERY time PIF comes up, never sell PIF as a tactic): tell the member to make contact and get an explicit commitment BEFORE sending anything. The transfer is wallet to wallet and irreversible, there is no refund or chargeback and support cannot claw it back. The recipient may go quiet, spend it on something else or never sign in again, and none of that breaks any rule. A tank member joined with nobody working with them and may never have engaged, so an unanswered PIF is the easiest money on the site to lose. Honest arithmetic: if they buy $20 the sponsor gets 50 percent back so a gift that works costs about half, and a gift to someone who never answers costs all of it. Never gift money they need, never borrow to do it. If a member asks "should I PIF someone from the tank", the answer starts with talk to them first, not with the mechanics.
|
||||
- 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 fund every campaign format except login ads; attention earns advertising, referrals earn money, and viewer rewards are never cash. DAILY SIGN-IN BONUS: separate from the claim, the first sign-in of each day pays 5 credits, rising by one per consecutive day up to 10; a missed day resets it. REPORT AN AD: every served ad carries a 'report' link (broken, inappropriate, spam, scam, other, plus a note) that emails the admin; the Shorts page uses reason buttons. 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.
|
||||
@@ -78,8 +168,12 @@ FACTS:
|
||||
- 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/<hash> (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.
|
||||
|
||||
PAGES (the ONLY addresses that exist; never invent others): home https://instantadpay.com, member dashboard https://instantadpay.com/my with tabs as hashes: /my#overview, /my#line (My line, holding tank, prospects), /my#pipeline, /my#buy (Buy packages, Qualified Start, Buy POL with a card), /my#campaigns (New campaign, campaign rows, Credit activity), /my#earn (Watch ads, Watch videos, Verified visits, Inbox), /my#earnings (Trace a payment, payouts, referrals, purchases), /my#promo (Your links, Social posts, Text a friend, Email swipes, Banners, Wall, Objections, Videos, Toolkit), /my#training, /my#wallet, /my#profile. Public: /join/<username> (invite page, ?v=angle), /wall/<username>, /b/<username>/<badge>, /handout/<username>, /ledger, /tx/<hash>, /contract, /leaderboard, /whats-new, /blog, /plays, /wallets (members), /launch (members), /partners, /earning, /shorts, /terms, /privacy, /disclaimer, /from/faucetwave, /from/tieroneads and /from/socialpix.
|
||||
|
||||
RULES:
|
||||
- 1 to 4 sentences, plain text, no markdown, include full URLs when pointing at a page.
|
||||
- 1 to 4 sentences, plain text, no markdown, include full URLs when pointing at a page, and only URLs from the PAGES list above.
|
||||
- Never send someone to a warning, notice, guide, document or page section that is not named above. Saying "read the full warning on your training page" invents something that does not exist and the member will go looking for it. If the answer is not on a page in the PAGES list, give the answer here or say you are not sure and point to /contract, the ledger, or their sponsor.
|
||||
- Plain ASCII only: a normal hyphen, never a non-breaking hyphen, and no smart quotes.
|
||||
- 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.`;
|
||||
}
|
||||
@@ -102,8 +196,17 @@ function askAI(message, historyText) {
|
||||
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))); }
|
||||
// OpenRouter pads a slow upstream with keepalive lines before the JSON, and when the
|
||||
// provider gives up it can close having sent ONLY that padding: HTTP 200, no body.
|
||||
// JSON.parse then threw and every question died on "I hit a snag" (Marty, 2026-09-17).
|
||||
// Find the actual JSON object, and treat an empty answer as a failure worth retrying.
|
||||
const i = d.indexOf('{');
|
||||
if (i < 0) return reject(new Error('openrouter ' + res.statusCode + ': empty body (' + d.length + ' bytes of keepalive)'));
|
||||
let txt = '';
|
||||
try { txt = String(JSON.parse(d.slice(i)).choices[0].message.content || '').trim(); }
|
||||
catch (e) { return reject(new Error('openrouter ' + res.statusCode + ': ' + d.slice(i, i + 150))); }
|
||||
if (!txt) return reject(new Error('openrouter ' + res.statusCode + ': empty content'));
|
||||
resolve(txt);
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
@@ -117,11 +220,17 @@ async function answer(message, historyText) {
|
||||
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://instantadpay.com/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://instantadpay.com/contract and the live ledger at https://instantadpay.com/ledger cover most deep questions.' };
|
||||
// One retry: the usual failure is a single provider dropping the request, and asking
|
||||
// again almost always lands. Only after both fail do we admit defeat.
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try { return { reply: await askAI(msg, historyText) }; }
|
||||
catch (e) {
|
||||
console.error('chat AI failed (attempt ' + (attempt + 1) + ')', e.message);
|
||||
if (attempt === 0) await new Promise(r => setTimeout(r, 700));
|
||||
}
|
||||
}
|
||||
// Never a dead end: say plainly that it was us, and offer a person.
|
||||
return { reply: 'That one did not come back to me just now, and that is our side, not your question. Ask me again in a moment and it should go through. If it is urgent, the plain-language contract review at https://instantadpay.com/contract and the live ledger at https://instantadpay.com/ledger cover most of the deep questions, and your sponsor can be reached from Members > My line.' };
|
||||
}
|
||||
|
||||
module.exports = { init, answer, enabled };
|
||||
module.exports = { init, answer, enabled, KNOWLEDGE_DATE };
|
||||
|
||||
@@ -76,6 +76,8 @@ async function coachView(email) {
|
||||
// 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
|
||||
@@ -99,8 +101,11 @@ const J = {
|
||||
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) { this.db.views.push({ token, angle, ts }); if (this.db.views.length > 50000) this.db.views = this.db.views.slice(-40000); this.save(); },
|
||||
async views(tokens, since) { return this.db.views.filter(v => tokens.includes(v.token) && v.ts >= since); }
|
||||
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; },
|
||||
@@ -120,11 +125,17 @@ const D = {
|
||||
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) { await db.q('INSERT INTO join_views (token,angle,ts) VALUES (?,?,?)', [token, angle, ts]); },
|
||||
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 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) }));
|
||||
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;
|
||||
@@ -148,7 +159,29 @@ async function saveProspect(email, body) {
|
||||
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 ----
|
||||
async function recordView(token, angle) { try { await impl().addView(String(token || '').toLowerCase().slice(0, 40), String(angle || '').toLowerCase().slice(0, 20), Date.now()); } catch (e) {} }
|
||||
// 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 || /instantadpay\.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 (!/instantadpay\.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: [] };
|
||||
@@ -156,16 +189,20 @@ async function linkStats(email) {
|
||||
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 ANG = ['', 'instant', 'adspend', 'free', 'ledger', 'two', 'page']; // page = a viral link (any page + ?ref=)
|
||||
const rows = {};
|
||||
for (const k of ANG) rows[k] = { angle: k || 'plain', 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 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;
|
||||
if (j.memberId) { const mm = await member(j.memberId); if (mm && mm.countedAsBuyer) r.buyers += 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; } }
|
||||
}
|
||||
return { angles: Object.values(rows), totalViews: views.length, totalJoins: joined.length };
|
||||
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 ----
|
||||
@@ -192,20 +229,53 @@ async function nudgeTick() {
|
||||
catch (e) { console.error('nudge', acct.email, e.message); }
|
||||
if (nudges >= 40) break; // spread the load across ticks
|
||||
}
|
||||
// weekly digest: every sponsor with at least one direct
|
||||
// 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);
|
||||
if (!view.directs.length) { await impl().setDigest(acct.email, now); continue; }
|
||||
const week = view.directs.filter(d => now - d.joined < 7 * DAY).length;
|
||||
const lines = view.directs.slice(0, 25).map(d => '- ' + d.name + ': ' + d.label + (d.stalled ? ' (quiet ' + d.quietDays + ' days)' : '') + '. Next: ' + d.next);
|
||||
const actions = view.directs.filter(d => d.stalled).slice(0, 3).map(d => '- Message ' + d.name + ': "' + d.say.replace('{{name}}', d.name.replace(/^@/, '')) + '"');
|
||||
const body = 'Your line this week:\n' + week + ' joined in the last 7 days. ' + view.stalled + ' of your ' + view.directs.length + ' directs have gone quiet.\n\n'
|
||||
+ lines.join('\n') + '\n\n' + (actions.length ? 'Three things to do today:\n' + actions.join('\n') + '\n\n' : '')
|
||||
+ 'Open My line to message anyone in one click: https://instantadpay.com/my#line\n\nInstantAdPay';
|
||||
try { await mailer.send(acct.email, 'Your line this week: ' + view.stalled + ' to nudge', body); digests += 1; } catch (e) { console.error('digest', acct.email, e.message); }
|
||||
// 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 InstantAdPay:'];
|
||||
// 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://instantadpay.com/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://instantadpay.com/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://instantadpay.com/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://instantadpay.com/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://instantadpay.com/blog/the-twenty-minute-day\n\nInstantAdPay · https://instantadpay.com/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 InstantAdPay' : 'Your week on InstantAdPay: 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;
|
||||
}
|
||||
@@ -213,8 +283,10 @@ async function nudgeTick() {
|
||||
return { nudges, digests };
|
||||
}
|
||||
|
||||
function init(opts) {
|
||||
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();
|
||||
}
|
||||
module.exports = { init, RUNGS, rungFor, describe, coachView, prospects, saveProspect, removeProspect, STATUSES, recordView, linkStats, nudgeTick };
|
||||
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 };
|
||||
|
||||
@@ -72,13 +72,16 @@ async function bootstrap() {
|
||||
) 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
|
||||
await alterSafe0('ALTER TABLE earned_credits ADD COLUMN login_ts BIGINT NOT NULL DEFAULT 0'); // when the last sign-in bonus was paid: enforces a 20h floor, whatever the day string says
|
||||
// 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');
|
||||
@@ -113,13 +116,27 @@ async function bootstrap() {
|
||||
// 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 drip_id INT NULL'); // syndicated DripOffers campaigns.id (2026-09-18)
|
||||
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)
|
||||
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 (
|
||||
@@ -131,6 +148,38 @@ async function bootstrap() {
|
||||
) 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 alterSafe0('ALTER TABLE promo_redemptions ADD COLUMN paid INT NOT NULL DEFAULT 0'); // credits paid so far (the drip)
|
||||
await alterSafe0('ALTER TABLE promo_redemptions ADD COLUMN last_day CHAR(10) NULL'); // Central day of the last installment
|
||||
await alterSafe0('ALTER TABLE promo_redemptions ADD COLUMN expires BIGINT NOT NULL DEFAULT 0'); // unclaimed installments stop here; 0 = pre-drip row
|
||||
await alterSafe0('ALTER TABLE promo_redemptions ADD COLUMN ref VARCHAR(80) NULL'); // site the ?promo= link was opened from
|
||||
await q('UPDATE promo_redemptions SET paid=credits WHERE expires=0 AND paid<credits'); // pre-drip redemptions were paid in full at once
|
||||
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,
|
||||
@@ -143,6 +192,15 @@ async function bootstrap() {
|
||||
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,
|
||||
@@ -150,6 +208,14 @@ async function bootstrap() {
|
||||
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,
|
||||
@@ -172,6 +238,30 @@ async function bootstrap() {
|
||||
// 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)');
|
||||
// 2026-09-18: system notices used to be stored as 'broadcast', which is the kind the
|
||||
// sign-in modal pops for — so a member with a backlog of payout notices got a fresh
|
||||
// popup after every ad view. Retag the machine-generated ones so they stay in the inbox.
|
||||
// Matched on subject, NOT on sender: these come from member 1 at ADMIN_EMAIL, which is
|
||||
// also Marty's own member address, so his genuine broadcasts sit under the same sender.
|
||||
// Idempotent — after the first pass nothing matches.
|
||||
for (const like of ['You just got paid %POL on InstantAdPay',
|
||||
'You missed %POL on InstantAdPay',
|
||||
'%is trying to buy. %',
|
||||
'You lost %payouts were not switched on%',
|
||||
'% is now in your line for good%']) {
|
||||
try { await q("UPDATE sponsor_messages SET kind='notice' WHERE kind='broadcast' AND subject LIKE ?", [like]); }
|
||||
catch (e) {}
|
||||
}
|
||||
// ...but two of those DO need the member to act, and they lose money by not acting: a sale
|
||||
// blocked or already lost because payouts are off, and a payout that passed them by. Those
|
||||
// keep interrupting. The dividing line is "does this need you to do something", not
|
||||
// "who sent it".
|
||||
for (const like of ['You missed %POL on InstantAdPay',
|
||||
'%is trying to buy. %',
|
||||
'You lost %payouts were not switched on%']) {
|
||||
try { await q("UPDATE sponsor_messages SET kind='alert' WHERE kind='notice' AND subject LIKE ?", [like]); }
|
||||
catch (e) {}
|
||||
}
|
||||
// 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');
|
||||
@@ -203,6 +293,18 @@ async function bootstrap() {
|
||||
burned_at BIGINT NULL,
|
||||
INDEX (burned_tx)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
// credit activity ledger: one row per real credit movement (metered delivery rolls up per campaign per day)
|
||||
await q(`CREATE TABLE IF NOT EXISTS credit_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(190) NOT NULL,
|
||||
ts BIGINT NOT NULL,
|
||||
day CHAR(10) NOT NULL,
|
||||
delta INT NOT NULL,
|
||||
kind VARCHAR(20) NOT NULL,
|
||||
note VARCHAR(160) NOT NULL,
|
||||
ref VARCHAR(40) NULL,
|
||||
INDEX (email, ts), INDEX (email, kind, ref, day)
|
||||
) 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,
|
||||
@@ -215,6 +317,7 @@ async function bootstrap() {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# InstantAdPay 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 InstantAdPay 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 InstantAdPay 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 InstantAdPay'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: instantadpay.com/join/yourname
|
||||
2. Send Marty your username and the site. He mints your code (for example YOURSITE) with the agreed credits.
|
||||
3. Add InstantAdPay to your builder with https://instantadpay.com/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
|
||||
|
||||
InstantAdPay 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, instantadpay.com, t.me/cryptoteambuild
|
||||
@@ -97,10 +97,11 @@ 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) return { ok: true, already: true };
|
||||
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) {
|
||||
@@ -178,4 +179,4 @@ async function tick() {
|
||||
}
|
||||
async function stats() { return impl().stats(); }
|
||||
|
||||
module.exports = { init, enqueue, tick, stats, sequence, setSequence, resetSequence, sendStep, unsubscribe, unsubUrl, DEFAULT_SEQUENCE };
|
||||
module.exports = { init, enqueue, tick, stats, sequence, setSequence, resetSequence, sendStep, unsubscribe, unsubUrl, isUnsubscribed, DEFAULT_SEQUENCE };
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Syndicate member campaigns to DripOffers as paid-per-click offers.
|
||||
//
|
||||
// Third syndication rail, and the one that behaves least like the other two. Network Ad Space
|
||||
// and AdRevLinks both serve impressions into a rotation. DripOffers serves *clicks*: a real
|
||||
// person picks the offer off an offerwall, visits the target URL, and has to stay the pack's
|
||||
// dwell time before anybody gets paid. So a DripOffers click is worth far more than an
|
||||
// impression, and there are far fewer of them.
|
||||
//
|
||||
// The DripOffers database is not reachable from this server, so we talk to a narrow
|
||||
// authenticated endpoint that runs there (public_html/iap-bridge.php) and does the insert
|
||||
// locally. Six actions, no general query surface.
|
||||
//
|
||||
// FEATURE-FLAGGED: inert unless DRIPOFFERS_BRIDGE_URL and DRIPOFFERS_BRIDGE_KEY are set.
|
||||
//
|
||||
// Three things carried over deliberately from the NAS and AdRevLinks work:
|
||||
//
|
||||
// 1. DELIVERY IS READ, NEVER DERIVED. `status` counts rows in that platform's permanent
|
||||
// per-click ledger. We never compute delivery from "ordered minus remaining", because
|
||||
// remaining is mutable and deriving from it is what charged members for impressions that
|
||||
// never ran on NAS.
|
||||
//
|
||||
// 2. EVERY WRITE IS IDEMPOTENT. Each campaign carries ref "iap:<id>" behind a UNIQUE index
|
||||
// on the far side, so a retry after a timeout cannot book a second campaign.
|
||||
//
|
||||
// 3. A SYNDICATION HICCUP NEVER BLOCKS THE MEMBER. Callers swallow errors; the IAP campaign
|
||||
// goes live either way.
|
||||
//
|
||||
// Capacity note (2026-09-18, corrected same day): the offerwall is a menu, not a rotator -
|
||||
// every eligible campaign is listed at once, ordered by reward, and the earner chooses. So
|
||||
// adding campaigns does not starve the others the way the AdRevLinks rotator does; it
|
||||
// lengthens the menu.
|
||||
//
|
||||
// What is finite is total clicks, and the real figure is SMALL: the permanent click ledger
|
||||
// shows roughly 40-50 clicks A DAY across the whole platform. (An earlier read of 2,700/day
|
||||
// was wrong - it came from campaigns.received_today, which is only a daily figure while the
|
||||
// nightly cron resets it, and that reset has stopped running.) Pack 2 puts our offers above
|
||||
// the house campaigns, and they are indeed taking the large majority of that trickle.
|
||||
//
|
||||
// Hence a deliberately small MAX_CLICKS: at this volume a 10,000-click booking would sit at
|
||||
// the top of the list for most of a year and crowd everything else out. Cost is not the reason
|
||||
// for the caps - Marty owns the platform and treats these placements as free.
|
||||
'use strict';
|
||||
const https = require('https');
|
||||
const { URL } = require('url');
|
||||
|
||||
const MIN_CREDITS = Number(process.env.DRIPOFFERS_MIN_CREDITS || 1000); // below this, not worth a listing
|
||||
const MAX_CLICKS = Number(process.env.DRIPOFFERS_MAX_CLICKS || 2500);
|
||||
const MIN_CLICKS = 500; // the platform's own floor
|
||||
const PACK_ID = Number(process.env.DRIPOFFERS_PACK_ID || 2); // 2 = a 10-second dwell
|
||||
const USER_ID = Number(process.env.DRIPOFFERS_USER_ID || 1); // the funding account
|
||||
|
||||
// Which IAP formats make sense as a click offer: the ones whose whole point is sending a
|
||||
// person to the target URL. Video and solo are a different product, and featured is a slot
|
||||
// buy on our own site rather than a destination.
|
||||
const KINDS = ['banner', 'text', 'visits'];
|
||||
|
||||
function enabled() {
|
||||
return !!(process.env.DRIPOFFERS_BRIDGE_URL && process.env.DRIPOFFERS_BRIDGE_KEY);
|
||||
}
|
||||
|
||||
function call(action, payload, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!enabled()) return reject(new Error('dripoffers-disabled'));
|
||||
const u = new URL(process.env.DRIPOFFERS_BRIDGE_URL);
|
||||
const body = JSON.stringify(Object.assign({ action }, payload || {}));
|
||||
const req = https.request({
|
||||
hostname: u.hostname, path: u.pathname + u.search, method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body),
|
||||
'X-Bridge-Key': process.env.DRIPOFFERS_BRIDGE_KEY },
|
||||
timeout: timeoutMs || 25000
|
||||
}, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => {
|
||||
let j = null;
|
||||
try { j = JSON.parse(d); } catch (e) {}
|
||||
if (!j) return reject(new Error('dripoffers ' + res.statusCode + ': ' + d.slice(0, 120)));
|
||||
if (j.error) return reject(new Error(j.error));
|
||||
resolve(j);
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => req.destroy(new Error('dripoffers timeout')));
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
const refFor = campaignId => 'iap:' + Number(campaignId);
|
||||
const kindOk = type => KINDS.includes(String(type || ''));
|
||||
|
||||
// How many clicks a campaign's remaining credits are worth, capped so one campaign cannot
|
||||
// park itself at the top of the offerwall for a month.
|
||||
function clicksFor(credits) {
|
||||
const v = Math.max(0, Math.floor(Number(credits) || 0));
|
||||
return Math.min(v, MAX_CLICKS);
|
||||
}
|
||||
|
||||
// Country targeting. Unlike the AdRevLinks popup rail - which only has prices for five
|
||||
// countries and so cannot carry a targeted campaign at all - this platform filters on an
|
||||
// explicit country list, so geo-restricted campaigns CAN syndicate here. That is the point of
|
||||
// including geo support: those campaigns currently reach no external rail at all.
|
||||
//
|
||||
// Tier 3 is the exception. It is defined as "everywhere the other two tiers are not", which an
|
||||
// inclusion list cannot express, so a tier-3-only campaign is skipped rather than sent
|
||||
// worldwide - sending it worldwide would quietly deliver the Tier 1 traffic its owner chose
|
||||
// not to target.
|
||||
function countriesFor(geo, tiers) {
|
||||
const g = String(geo || '').trim();
|
||||
if (!g) return '*';
|
||||
const want = g.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (want.length === 3) return '*';
|
||||
const out = [];
|
||||
if (want.includes('1')) out.push(...(tiers && tiers.t1 ? tiers.t1 : []));
|
||||
if (want.includes('2')) out.push(...(tiers && tiers.t2 ? tiers.t2 : []));
|
||||
if (!out.length) return null; // tier 3 only: not expressible, skip
|
||||
return [...new Set(out.map(c => String(c).toUpperCase()))].join(',');
|
||||
}
|
||||
|
||||
// What a click-earner sees in the offerwall list. A campaign's `name` is the member's own
|
||||
// internal label and is often too short to mean anything to a stranger ("MG"), so the ad's
|
||||
// headline comes first, then the label, and a very short label is qualified with the
|
||||
// destination host rather than dropped or dressed up in copy we invented for them. The
|
||||
// platform's own floor is five characters.
|
||||
function titleFor(campaign, url) {
|
||||
const clean = v => String(v || '').replace(/\s+/g, ' ').trim();
|
||||
const head = clean(campaign.title), label = clean(campaign.name);
|
||||
let host = '';
|
||||
try { host = new URL(url).hostname.replace(/^www\./, ''); } catch (e) {}
|
||||
const pick = head.length >= 5 ? head
|
||||
: label.length >= 5 ? label
|
||||
: label && host ? label + ' (' + host + ')'
|
||||
: host;
|
||||
return pick.length >= 5 ? pick.slice(0, 250) : null;
|
||||
}
|
||||
|
||||
// Push a campaign. Returns null when it is not worth syndicating (or cannot be targeted
|
||||
// faithfully) rather than throwing, so the caller treats "too small", "wrong format" and
|
||||
// "disabled" the same quiet way.
|
||||
async function push(campaign, opts) {
|
||||
if (!enabled()) return null;
|
||||
if (!kindOk(campaign.type)) return null;
|
||||
const credits = Number((campaign.budget || 0) - (campaign.spent || 0) - (campaign.accrued || 0));
|
||||
if (credits < MIN_CREDITS) return null;
|
||||
const clicks = clicksFor(credits);
|
||||
if (clicks < MIN_CLICKS) return null;
|
||||
|
||||
const url = String(campaign.targetUrl || campaign.target_url || '').trim();
|
||||
if (!/^https?:\/\//i.test(url)) return null;
|
||||
|
||||
const countries = countriesFor(campaign.geo, (opts && opts.tiers) || null);
|
||||
if (countries === null) return null;
|
||||
|
||||
const title = titleFor(campaign, url);
|
||||
if (!title) return null;
|
||||
// The offerwall shows this line under the title, so it has to read like an offer rather
|
||||
// than like an internal campaign record.
|
||||
const description = String(campaign.title || campaign.body || campaign.name || title).slice(0, 250);
|
||||
|
||||
return await call('create_ptc', {
|
||||
title, description, url, clicks, countries,
|
||||
device: 0,
|
||||
pack_id: (opts && opts.packId) || PACK_ID,
|
||||
user_id: USER_ID,
|
||||
ref: refFor(campaign.id),
|
||||
activate: !(opts && opts.paused)
|
||||
});
|
||||
}
|
||||
|
||||
// Real delivery, counted from the platform's own click ledger. Never derived.
|
||||
async function readServed(campaignId) {
|
||||
if (!enabled()) return null;
|
||||
try { return await call('status', { ref: refFor(campaignId) }); }
|
||||
catch (e) { if (/not found/i.test(e.message)) return null; throw e; }
|
||||
}
|
||||
|
||||
async function pause(campaignId) { return enabled() ? call('pause', { ref: refFor(campaignId) }) : null; }
|
||||
async function resume(campaignId) { return enabled() ? call('resume', { ref: refFor(campaignId) }) : null; }
|
||||
async function remove(campaignId) {
|
||||
if (!enabled()) return null;
|
||||
try { return await call('delete', { ref: refFor(campaignId) }); }
|
||||
catch (e) { if (/not found/i.test(e.message)) return null; throw e; }
|
||||
}
|
||||
async function packs() { return enabled() ? call('packs', {}) : null; }
|
||||
|
||||
module.exports = { enabled, push, readServed, pause, resume, remove, packs, titleFor,
|
||||
clicksFor, countriesFor, kindOk, refFor,
|
||||
MIN_CREDITS, MAX_CLICKS, MIN_CLICKS, PACK_ID, KINDS };
|
||||
@@ -0,0 +1,39 @@
|
||||
// One proof-feed message per purchase (Marty, 2026-09-25).
|
||||
//
|
||||
// The feed used to post each tier payout on its own line and, in payouts mode, never said who
|
||||
// bought what. Marty: "I see the level 1 payout, I see a level 2 payout, but I don't see who
|
||||
// purchased something and what they bought." So: the buyer and the package first, then the
|
||||
// level payouts under it, then any pass-ups, as ONE message. It composes from every event of
|
||||
// the transaction, which is why the caller triggers it on AdminPaid, the last event the contract
|
||||
// emits for a purchase: by then Purchase, TierPaid and PassedUp of that tx are all indexed.
|
||||
//
|
||||
// Pure: events in, HTML string out. Tested against real index rows before it shipped.
|
||||
'use strict';
|
||||
|
||||
const PCT = { 1: 50, 2: 20, 3: 10 };
|
||||
|
||||
function usd(cents) { return ('$' + (cents / 100).toFixed(2)).replace(/\.00$/, ''); }
|
||||
|
||||
// txEvents: every indexed event with this tx. who(id) -> "#12 @name". weiToPol(wei) -> "1.23".
|
||||
// Returns null when there is no Purchase in the tx (not a package purchase) or when
|
||||
// `requirePayout` is set and no member was paid (a root or unsponsored buyer: nothing to prove).
|
||||
function compose(txEvents, who, weiToPol, opts) {
|
||||
opts = opts || {};
|
||||
const pur = txEvents.find(e => e.type === 'Purchase');
|
||||
if (!pur) return null;
|
||||
const tiers = txEvents.filter(e => e.type === 'TierPaid').sort((a, b) => (a.tier || 0) - (b.tier || 0) || (a.li || 0) - (b.li || 0));
|
||||
if (opts.requirePayout && !tiers.length) return null;
|
||||
const lines = ['\u{1F9FE} <b>' + who(pur.buyerId) + '</b> bought a <b>' + usd(pur.priceCents) + ' package</b>'];
|
||||
for (const t of tiers) {
|
||||
const pct = PCT[t.tier];
|
||||
const about = pct ? ' (' + usd(Math.round(pur.priceCents * pct / 100)) + ')' : '';
|
||||
lines.push('\u{1F4B8} Level ' + t.tier + ': <b>' + weiToPol(t.amountWei) + ' POL</b>' + about + ' → ' + who(t.recipientId)
|
||||
+ (t.hops > 0 ? ' <i>(passed up ' + t.hops + ')</i>' : ''));
|
||||
}
|
||||
if (!tiers.length) lines.push('<i>No member payout on this one: the buyer has nobody above them to pay.</i>');
|
||||
const passed = txEvents.filter(e => e.type === 'PassedUp');
|
||||
for (const p of passed) if (p.skippedId) lines.push('⏭ Level ' + (p.tier || '?') + ' skipped ' + who(p.skippedId) + ' (not yet qualified)');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
module.exports = { compose };
|
||||
@@ -0,0 +1,218 @@
|
||||
// InstantAdPay anti-fraud signals (Marty, 2026-09-16, after @megamol created megamol2/megamol3 under
|
||||
// his own link and bought $20 on each to fake his two qualifying buyers).
|
||||
//
|
||||
// Per account: sign-up IP + user agent + browser device id (first-party cookie iap.dev), and the
|
||||
// last-seen IP/UA/device on every sign-in. At sign-up:
|
||||
// HARD BLOCK dup-device another account already used this browser
|
||||
// HARD BLOCK ip-burst more than fraudMaxSignupsPerIpDay accounts from this IP in 24h
|
||||
// HARD BLOCK sponsor-device the sponsor's account used this same browser
|
||||
// FLAG sponsor-ip the sponsor signed up from / was last seen on this IP (households are legal, so flag only)
|
||||
// FLAG shared-ip another account used this IP in the last 30 days
|
||||
// Flagged accounts stay usable but never count on the leaderboard, cannot adopt from the holding tank,
|
||||
// and show in Admin > Members. Suspended accounts (admin switch) cannot sign in at all.
|
||||
// The contract's on-chain payments are outside all of this and are never reversed.
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const db = require('./db');
|
||||
|
||||
let DATA_DIR = null;
|
||||
const FILE = () => path.join(DATA_DIR, 'account-signals.json');
|
||||
const J = { db: null, load() { try { this.db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { this.db = {}; } }, save() { try { fs.writeFileSync(FILE(), JSON.stringify(this.db)); } catch (e) {} } };
|
||||
let suspendedSet = new Set(); // refreshed on init and on every suspend/unsuspend
|
||||
let flaggedSet = new Set(); // accounts carrying a hard flag (excluded from leaderboard / adoption)
|
||||
const QS = ' To hold more than one position, sign in to your account and use Qualified Start on the Buy packages tab: link another wallet of your own as a position under your account. Each linked wallet that buys a $20 or larger package counts as one of YOUR qualifying buyers, its credits pool with yours, and half of that purchase comes straight back to your main wallet. That is the built-in way to self-qualify.';
|
||||
const HARD = new Set(['dup-device', 'ip-burst', 'sponsor-device', 'sponsor-ip', 'multi-account']);
|
||||
let seenAt = new Map(); // email -> ts of last recordSeen (throttle writes)
|
||||
|
||||
function norm(e) { return String(e || '').trim().toLowerCase(); }
|
||||
function ipOf(req) { return String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim().replace(/^::ffff:/, '').slice(0, 45); }
|
||||
function uaOf(req) { return String(req.headers['user-agent'] || '').slice(0, 200); }
|
||||
function cookies(req) { const out = {}; String(req.headers.cookie || '').split(';').forEach(p => { const i = p.indexOf('='); if (i > 0) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); }); return out; }
|
||||
function deviceOf(req) { const d = cookies(req)['iap.dev'] || ''; return /^[a-f0-9]{32}$/.test(d) ? d : ''; }
|
||||
function newDeviceId() { return crypto.randomBytes(16).toString('hex'); }
|
||||
function deviceCookie(id, isProd) { return 'iap.dev=' + id + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' + (400 * 86400) + (isProd ? '; Secure' : ''); }
|
||||
|
||||
// ---- exception list ----
|
||||
// Marty approves specific people to hold more than one account (partners, staff, a spouse
|
||||
// on a shared machine). Their sign-ups must not be blocked, must not carry a hard flag,
|
||||
// and must not be quietly dropped from the leaderboard or barred from adopting. Kept in
|
||||
// its own small file so it works the same in DB mode and JSON mode, and so an allow entry
|
||||
// can be added BEFORE the second account exists.
|
||||
const ALLOW_FILE = () => path.join(DATA_DIR, 'fraud-allow.json');
|
||||
let allowDb = null; // { "<email>": { note, at, by } }
|
||||
function allowLoad() { if (allowDb) return allowDb; try { allowDb = JSON.parse(fs.readFileSync(ALLOW_FILE(), 'utf8')); } catch (e) { allowDb = {}; } if (!allowDb || typeof allowDb !== 'object') allowDb = {}; return allowDb; }
|
||||
function allowSave() { try { fs.writeFileSync(ALLOW_FILE(), JSON.stringify(allowLoad())); } catch (e) { console.error('fraud allow save', e.message); } }
|
||||
function isAllowed(email) { const e = norm(email); return !!(e && allowLoad()[e]); }
|
||||
function allowList() { const d = allowLoad(); return Object.keys(d).sort().map(e => ({ email: e, note: d[e].note || '', at: d[e].at || 0, by: d[e].by || '' })); }
|
||||
function allowAdd(email, note, by) {
|
||||
const e = norm(email);
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return { error: 'That email address does not look right.' };
|
||||
allowLoad()[e] = { note: String(note || '').slice(0, 200), at: Date.now(), by: String(by || '').slice(0, 120) };
|
||||
allowSave();
|
||||
return { ok: true, list: allowList() };
|
||||
}
|
||||
function allowRemove(email) { const e = norm(email); const d = allowLoad(); if (d[e]) { delete d[e]; allowSave(); } return { ok: true, list: allowList() }; }
|
||||
|
||||
// The opposite list. An address here can never open an account, through any door. Same
|
||||
// shape and file pattern as the allow list so it behaves identically in DB and JSON mode.
|
||||
const BLOCK_FILE = () => path.join(DATA_DIR, 'fraud-block.json');
|
||||
let blockDb = null; // { "<email>": { note, at, by } }
|
||||
function blockLoad() { if (blockDb) return blockDb; try { blockDb = JSON.parse(fs.readFileSync(BLOCK_FILE(), 'utf8')); } catch (e) { blockDb = {}; } if (!blockDb || typeof blockDb !== 'object') blockDb = {}; return blockDb; }
|
||||
function blockSave() { try { fs.writeFileSync(BLOCK_FILE(), JSON.stringify(blockLoad())); } catch (e) { console.error('fraud block save', e.message); } }
|
||||
function isBlocked(email) { const e = norm(email); return !!(e && blockLoad()[e]); }
|
||||
function blockList() { const d = blockLoad(); return Object.keys(d).sort().map(e => ({ email: e, note: d[e].note || '', at: d[e].at || 0, by: d[e].by || '' })); }
|
||||
function blockAdd(email, note, by) {
|
||||
const e = norm(email); if (!e || !e.includes('@')) return { error: 'Enter an email address.' };
|
||||
blockLoad()[e] = { note: String(note || '').slice(0, 200), at: Date.now(), by: String(by || '').slice(0, 120) };
|
||||
blockSave();
|
||||
return { ok: true, list: blockList() };
|
||||
}
|
||||
function blockRemove(email) { const e = norm(email); const d = blockLoad(); if (d[e]) { delete d[e]; blockSave(); } return { ok: true, list: blockList() }; }
|
||||
|
||||
async function init(opts) {
|
||||
DATA_DIR = opts.dataDir;
|
||||
if (db.enabled()) {
|
||||
await db.q(`CREATE TABLE IF NOT EXISTS account_signals (
|
||||
email VARCHAR(190) PRIMARY KEY,
|
||||
signup_ip VARCHAR(45) NULL, signup_ua VARCHAR(200) NULL, device_id CHAR(32) NULL, signup_at BIGINT NULL,
|
||||
last_ip VARCHAR(45) NULL, last_ua VARCHAR(200) NULL, last_device CHAR(32) NULL, last_at BIGINT NULL,
|
||||
flags VARCHAR(400) NULL, suspended TINYINT NOT NULL DEFAULT 0, suspended_reason VARCHAR(200) NULL, suspended_at BIGINT NULL,
|
||||
INDEX (signup_ip), INDEX (device_id), INDEX (last_ip), INDEX (last_device), INDEX (suspended)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
} else J.load();
|
||||
await refreshSets();
|
||||
}
|
||||
async function refreshSets() {
|
||||
const rows = await all();
|
||||
suspendedSet = new Set(rows.filter(r => r.suspended).map(r => r.email));
|
||||
flaggedSet = new Set(rows.filter(r => (r.flags || []).some(f => HARD.has(f))).map(r => r.email));
|
||||
}
|
||||
async function all() {
|
||||
if (db.enabled()) return (await db.q('SELECT * FROM account_signals')).map(rowPub);
|
||||
if (!J.db) J.load();
|
||||
return Object.values(J.db).map(pubJ);
|
||||
}
|
||||
const rowPub = r => ({ email: r.email, signupIp: r.signup_ip || '', signupUa: r.signup_ua || '', deviceId: r.device_id || '', signupAt: Number(r.signup_at || 0),
|
||||
lastIp: r.last_ip || '', lastUa: r.last_ua || '', lastDevice: r.last_device || '', lastAt: Number(r.last_at || 0),
|
||||
flags: parseFlags(r.flags), suspended: !!r.suspended, suspendedReason: r.suspended_reason || '', suspendedAt: Number(r.suspended_at || 0) });
|
||||
const pubJ = a => ({ email: a.email, signupIp: a.signupIp || '', signupUa: a.signupUa || '', deviceId: a.deviceId || '', signupAt: a.signupAt || 0,
|
||||
lastIp: a.lastIp || '', lastUa: a.lastUa || '', lastDevice: a.lastDevice || '', lastAt: a.lastAt || 0,
|
||||
flags: a.flags || [], suspended: !!a.suspended, suspendedReason: a.suspendedReason || '', suspendedAt: a.suspendedAt || 0 });
|
||||
function parseFlags(s) { try { const v = JSON.parse(s || '[]'); return Array.isArray(v) ? v : []; } catch (e) { return []; } }
|
||||
async function get(email) {
|
||||
const e = norm(email); if (!e) return null;
|
||||
if (db.enabled()) { const r = await db.q('SELECT * FROM account_signals WHERE email=?', [e]); return r.length ? rowPub(r[0]) : null; }
|
||||
if (!J.db) J.load(); return J.db[e] ? pubJ(J.db[e]) : null;
|
||||
}
|
||||
async function upsert(email, fields) {
|
||||
const e = norm(email); if (!e) return;
|
||||
if (db.enabled()) {
|
||||
const cur = await get(e);
|
||||
const v = Object.assign({ signupIp: null, signupUa: null, deviceId: null, signupAt: null, lastIp: null, lastUa: null, lastDevice: null, lastAt: null, flags: [], suspended: false, suspendedReason: null, suspendedAt: null }, cur || {}, fields);
|
||||
await db.q(`INSERT INTO account_signals (email,signup_ip,signup_ua,device_id,signup_at,last_ip,last_ua,last_device,last_at,flags,suspended,suspended_reason,suspended_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE signup_ip=VALUES(signup_ip), signup_ua=VALUES(signup_ua), device_id=VALUES(device_id), signup_at=VALUES(signup_at),
|
||||
last_ip=VALUES(last_ip), last_ua=VALUES(last_ua), last_device=VALUES(last_device), last_at=VALUES(last_at), flags=VALUES(flags), suspended=VALUES(suspended), suspended_reason=VALUES(suspended_reason), suspended_at=VALUES(suspended_at)`,
|
||||
[e, v.signupIp || null, v.signupUa || null, v.deviceId || null, v.signupAt || null, v.lastIp || null, v.lastUa || null, v.lastDevice || null, v.lastAt || null, JSON.stringify(v.flags || []), v.suspended ? 1 : 0, v.suspendedReason || null, v.suspendedAt || null]);
|
||||
return;
|
||||
}
|
||||
if (!J.db) J.load();
|
||||
J.db[e] = Object.assign({ email: e }, J.db[e] || {}, fields); J.save();
|
||||
}
|
||||
|
||||
// Has this BROWSER ever had an account? Used to stop the sign-in page telling an existing
|
||||
// member "You're joining the line of @someone" just because they once clicked an invite
|
||||
// link and the 30-day last-touch cookie is still around (Marty, 2026-09-17).
|
||||
async function hasAccountOnDevice(req) {
|
||||
const dev = deviceOf(req);
|
||||
if (!dev) return false;
|
||||
try { const rows = await all(); return rows.some(r => r.deviceId === dev || r.lastDevice === dev); }
|
||||
catch (e) { return false; }
|
||||
}
|
||||
|
||||
// ---- sign-up time ----
|
||||
async function checkSignup(req, sponsorAccount, cfg, email) {
|
||||
const ip = ipOf(req), dev = deviceOf(req); const flags = []; let block = null;
|
||||
const rows = await all(); const now = Date.now();
|
||||
const maxPerDay = Math.max(1, Number(cfg && cfg.fraudMaxSignupsPerIpDay) || 2);
|
||||
const blockDevice = !(cfg && String(cfg.fraudBlockSharedDevice) === 'off');
|
||||
// Everyone this sign-up collides with, so an approved exception can clear it below.
|
||||
const collided = [];
|
||||
if (dev) {
|
||||
const same = rows.filter(r => r.deviceId === dev || r.lastDevice === dev);
|
||||
if (same.length) { collided.push(...same.map(r => r.email)); flags.push('dup-device'); if (blockDevice) block = 'This browser already has an InstantAdPay account (' + mask(same[0].email) + '), and it is one account per person.' + QS + ' Shared computer at work or a library? Contact support.'; }
|
||||
}
|
||||
if (ip) {
|
||||
const burst = rows.filter(r => r.signupIp === ip && now - (r.signupAt || 0) < 86400000);
|
||||
if (burst.length >= maxPerDay) { flags.push('ip-burst'); block = block || 'Too many new accounts from this connection today, and it is one account per person.' + QS + ' If these are different people on one connection, try again tomorrow or contact support.'; }
|
||||
const shared = rows.filter(r => (r.signupIp === ip || r.lastIp === ip) && now - Math.max(r.signupAt || 0, r.lastAt || 0) < 30 * 86400000);
|
||||
if (shared.length) flags.push('shared-ip');
|
||||
}
|
||||
if (sponsorAccount && sponsorAccount.email) {
|
||||
const sp = await get(sponsorAccount.email);
|
||||
if (sp) {
|
||||
if (dev && (sp.deviceId === dev || sp.lastDevice === dev)) { collided.push(sp.email); flags.push('sponsor-device'); if (blockDevice) block = block || 'The invite link you used belongs to an account on this same browser, so this would be a second account for the same person, which the Terms do not allow.' + QS; }
|
||||
if (ip && (sp.signupIp === ip || sp.lastIp === ip)) flags.push('sponsor-ip');
|
||||
}
|
||||
}
|
||||
// APPROVED EXCEPTION (Marty, 2026-09-17). Allow either the address signing up, or any
|
||||
// account it collided with: whitelisting somebody's existing account is the usual case,
|
||||
// because their second address is not known yet. Clearing the HARD flags matters as much
|
||||
// as clearing the block, since those are what drop an account off the leaderboard and
|
||||
// out of the holding tank. 'allowlisted' is recorded so the admin sees why it went through.
|
||||
const okEmail = isAllowed(email);
|
||||
const okOther = collided.some(e => isAllowed(e));
|
||||
if (okEmail || okOther) {
|
||||
block = null;
|
||||
const kept = flags.filter(f => !HARD.has(f));
|
||||
kept.push('allowlisted');
|
||||
return { block: null, flags: [...new Set(kept)], ip, device: dev, allowlisted: true,
|
||||
allowedBy: okEmail ? norm(email) : collided.find(e => isAllowed(e)) };
|
||||
}
|
||||
return { block, flags: [...new Set(flags)], ip, device: dev };
|
||||
}
|
||||
async function recordSignup(email, req, flags) {
|
||||
const now = Date.now();
|
||||
await upsert(email, { signupIp: ipOf(req), signupUa: uaOf(req), deviceId: deviceOf(req) || null, signupAt: now, lastIp: ipOf(req), lastUa: uaOf(req), lastDevice: deviceOf(req) || null, lastAt: now, flags: flags || [] });
|
||||
if ((flags || []).some(f => HARD.has(f))) flaggedSet.add(norm(email));
|
||||
}
|
||||
async function recordSeen(email, req) {
|
||||
const e = norm(email); if (!e) return;
|
||||
const last = seenAt.get(e) || 0; if (Date.now() - last < 10 * 60 * 1000) return; seenAt.set(e, Date.now());
|
||||
if (seenAt.size > 20000) seenAt.clear();
|
||||
const cur = await get(e);
|
||||
const fields = { lastIp: ipOf(req), lastUa: uaOf(req), lastDevice: deviceOf(req) || (cur ? cur.lastDevice : null), lastAt: Date.now() };
|
||||
if (!cur) Object.assign(fields, { signupIp: null, signupUa: null, deviceId: deviceOf(req) || null, signupAt: null, flags: [] });
|
||||
await upsert(e, fields);
|
||||
}
|
||||
|
||||
// ---- admin ----
|
||||
async function addFlags(email, flags) {
|
||||
const cur = (await get(email)) || { flags: [] };
|
||||
const merged = [...new Set([...(cur.flags || []), ...(flags || [])])];
|
||||
await upsert(email, { flags: merged });
|
||||
if (merged.some(f => HARD.has(f))) flaggedSet.add(norm(email)); else flaggedSet.delete(norm(email));
|
||||
return merged;
|
||||
}
|
||||
async function clearFlags(email) { await upsert(email, { flags: [] }); flaggedSet.delete(norm(email)); }
|
||||
async function suspend(email, reason) { await upsert(email, { suspended: true, suspendedReason: String(reason || '').slice(0, 200), suspendedAt: Date.now() }); suspendedSet.add(norm(email)); }
|
||||
async function unsuspend(email) { await upsert(email, { suspended: false, suspendedReason: null, suspendedAt: null }); suspendedSet.delete(norm(email)); }
|
||||
function isSuspended(email) { return suspendedSet.has(norm(email)); }
|
||||
// Suspension still wins: an allow entry is permission to hold several accounts, not
|
||||
// immunity from being suspended for something else.
|
||||
function excluded(email) { const e = norm(email); if (suspendedSet.has(e)) return true; if (isAllowed(e)) return false; return flaggedSet.has(e); } // leaderboard / adoption
|
||||
async function report() {
|
||||
const rows = await all();
|
||||
const groups = (key) => { const m = new Map(); for (const r of rows) { const k = r[key]; if (!k) continue; if (!m.has(k)) m.set(k, []); m.get(k).push(r.email); } return [...m.entries()].filter(([, v]) => new Set(v).size > 1).map(([k, v]) => ({ key: k, emails: [...new Set(v)] })); };
|
||||
const byDevice = groups('deviceId').concat(groups('lastDevice'));
|
||||
const byIp = groups('signupIp').concat(groups('lastIp'));
|
||||
const dedupe = (list) => { const seen = new Set(); return list.filter(g => { const k = g.emails.slice().sort().join('|'); if (seen.has(k)) return false; seen.add(k); return true; }); };
|
||||
return { flagged: rows.filter(r => (r.flags || []).length).map(r => ({ email: r.email, flags: r.flags, signupIp: r.signupIp, lastIp: r.lastIp, suspended: r.suspended })),
|
||||
suspended: rows.filter(r => r.suspended).map(r => ({ email: r.email, reason: r.suspendedReason, at: r.suspendedAt })),
|
||||
sharedDevice: dedupe(byDevice), sharedIp: dedupe(byIp), total: rows.length };
|
||||
}
|
||||
function mask(e) { return String(e || '').replace(/^(.{2}).*(@.*)$/, '$1***$2'); }
|
||||
|
||||
module.exports = { init, checkSignup, recordSignup, recordSeen, addFlags, clearFlags, suspend, unsuspend, isSuspended, excluded, report, get, deviceOf, hasAccountOnDevice, newDeviceId, deviceCookie, ipOf, HARD,
|
||||
isAllowed, allowList, allowAdd, allowRemove, isBlocked, blockList, blockAdd, blockRemove };
|
||||
@@ -0,0 +1,152 @@
|
||||
// Five Dollar Friday (Marty, 2026-09-20). Every Friday, Central time, any ad package of $5 or more
|
||||
// earns +20% bonus credits (purchased-grade, so they run login ads too), credited the moment the
|
||||
// purchase indexes and labelled in the credit log. The day's wave is shown live (landing strip,
|
||||
// Overview card, /api/friday), posted to Telegram at every ten packs and wrapped up at midnight,
|
||||
// and five Fridays in a row earn the Five Fridays badge. Thursday 6 PM CT: an on-site notice and
|
||||
// an email to active members (drip unsubscribes honoured). Friday 9 AM CT: the Telegram kickoff.
|
||||
//
|
||||
// Settings (siteConfig): fridayPromo '1'|'0', fridayBonusPct (20), fridayStart 'YYYY-MM-DD' (first
|
||||
// Friday, 2026-09-25). Everything reads the chain index, never a marketing counter.
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let X = {}, FILE = null, S = null;
|
||||
const TZ = 'America/Chicago';
|
||||
const dayOf = ts => new Date(ts).toLocaleDateString('en-CA', { timeZone: TZ });
|
||||
const weekdayOf = ts => new Date(ts).toLocaleDateString('en-US', { timeZone: TZ, weekday: 'short' });
|
||||
const hourOf = ts => Number(new Date(ts).toLocaleString('en-US', { timeZone: TZ, hour: 'numeric', hour12: false }));
|
||||
const isFriday = ts => weekdayOf(ts) === 'Fri';
|
||||
|
||||
function init(opts) { X = opts; FILE = path.join(opts.dataDir, 'friday.json'); S = load(); }
|
||||
function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (e) { return { granted: {}, posts: {}, badges: {}, sent: {} }; } }
|
||||
function save() { try { fs.writeFileSync(FILE, JSON.stringify(S)); } catch (e) {} }
|
||||
// shared across instances when the app is wired with marks (server.js); true when nobody else has it
|
||||
async function claimOnce(key) { try { return X.marks ? await X.marks.claim(key) : true; } catch (e) { return true; } }
|
||||
|
||||
function cfg() {
|
||||
const c = (X.siteConfig && X.siteConfig()) || {};
|
||||
return {
|
||||
on: String(c.fridayPromo == null ? '1' : c.fridayPromo) === '1',
|
||||
pct: Math.max(0, Number(c.fridayBonusPct) || 20),
|
||||
start: String(c.fridayStart || '2026-09-25'),
|
||||
};
|
||||
}
|
||||
// the next Friday on or after today (Central), as a day key and a label
|
||||
function nextFriday(now) {
|
||||
const t0 = now || Date.now();
|
||||
for (let d = 0; d < 8; d++) { const ts = t0 + d * 86400000; if (isFriday(ts) && dayOf(ts) >= cfg().start) return dayOf(ts); }
|
||||
return null;
|
||||
}
|
||||
function labelOf(dayKey) { const [y, m, d] = dayKey.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d, 12)).toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' }); }
|
||||
// is the promo live right now (a Friday on or after the start, switched on)
|
||||
function liveNow(now) { const c = cfg(); const t = now || Date.now(); return c.on && isFriday(t) && dayOf(t) >= c.start; }
|
||||
function counts(dayKey) { return purchasesOn(dayKey).length; }
|
||||
|
||||
// every $5+ purchase on a given Central day, from the chain index
|
||||
function purchasesOn(dayKey) {
|
||||
const out = [];
|
||||
try { for (const ev of X.chain.recentEvents(1e9)) if (ev.type === 'Purchase' && ev.priceCents >= 500 && dayOf(ev.ts) === dayKey) out.push(ev); } catch (e) {}
|
||||
return out;
|
||||
}
|
||||
function paidOn(dayKey) {
|
||||
let wei = 0n, n = 0;
|
||||
try { for (const ev of X.chain.recentEvents(1e9)) if ((ev.type === 'TierPaid' || ev.type === 'AwardPaid') && dayOf(ev.ts) === dayKey) { wei += BigInt(ev.amountWei); n++; } } catch (e) {}
|
||||
return { pol: Number(wei / 10n ** 14n) / 10000, n };
|
||||
}
|
||||
function stats(dayKey) {
|
||||
const day = dayKey || dayOf(Date.now());
|
||||
const p = purchasesOn(day); const paid = paidOn(day);
|
||||
const usd = p.reduce((s, e) => s + e.priceCents, 0) / 100;
|
||||
const buyers = new Set(p.map(e => e.buyerId)).size;
|
||||
const bonus = Object.values(S.granted || {}).filter(g => g.day === day).reduce((s, g) => s + g.bonus, 0);
|
||||
return { day, packs: p.length, usd, buyers, polPaid: paid.pol, payouts: paid.n, bonusCredits: bonus };
|
||||
}
|
||||
// public view
|
||||
function view() {
|
||||
const c = cfg(); const now = Date.now(); const live = liveNow(now); const next = nextFriday(now);
|
||||
return { on: c.on, pct: c.pct, live, today: live ? stats(dayOf(now)) : null, nextFriday: next, nextLabel: next ? labelOf(next) : null, start: c.start };
|
||||
}
|
||||
|
||||
// the bonus: once per purchase tx, on a Friday on or after the start
|
||||
async function onEvent(ev) {
|
||||
if (!ev || ev.type !== 'Purchase' || !liveNow(ev.ts) || ev.priceCents < 500) return null;
|
||||
if (S.granted[ev.tx]) return null;
|
||||
const c = cfg(); const bonus = Math.round((Number(ev.creditAmount) || 0) * c.pct / 100);
|
||||
if (bonus <= 0) return null;
|
||||
const a = await X.accounts.byMemberId(ev.buyerId); if (!a || !a.email) return null;
|
||||
const usd = ('$' + (ev.priceCents / 100).toFixed(2)).replace(/\.00$/, '');
|
||||
await X.ads.addEarned(a.email, bonus, { purchased: true, log: { kind: 'bonus', note: 'Five Dollar Friday: +' + c.pct + '% on your ' + usd + ' package', ref: 'fdf:' + String(ev.tx).slice(0, 30) } });
|
||||
S.granted[ev.tx] = { day: dayOf(ev.ts), memberId: ev.buyerId, bonus, at: Date.now() }; save();
|
||||
try { await X.notify(ev.buyerId, 'Five Dollar Friday: +' + bonus + ' bonus credits', 'Your ' + usd + ' package landed on a Five Dollar Friday, so ' + bonus + ' bonus credits (+' + c.pct + '%) were added to your balance. Every pack bought on a Friday gets this. See your balance: https://instantadpay.com/my#campaigns', 'notice'); } catch (e) {}
|
||||
// the wave, every ten packs
|
||||
const st = stats(dayOf(ev.ts));
|
||||
if (st.packs > 0 && st.packs % 10 === 0 && !S.posts[st.day + ':' + st.packs]) {
|
||||
S.posts[st.day + ':' + st.packs] = Date.now(); save();
|
||||
X.telegram('\u{1F4B5} <b>Five Dollar Friday</b> · <b>' + st.packs + ' packs</b> so far today from ' + st.buyers + ' buyers, <b>' + st.polPaid.toFixed(2) + ' POL</b> paid to members in the same transactions.\nEvery $5+ pack today earns +' + c.pct + '% credits: https://instantadpay.com/my#buy').catch(() => {});
|
||||
}
|
||||
return bonus;
|
||||
}
|
||||
|
||||
// five Fridays in a row (the most recent five promo Fridays that have happened), any $5+ pack each
|
||||
function fridayStreak(memberId) {
|
||||
const c = cfg(); const now = Date.now(); const days = [];
|
||||
for (let d = 0; d < 7 * 8 && days.length < 5; d++) { const ts = now - d * 86400000; const k = dayOf(ts); if (isFriday(ts) && k >= c.start && (k < dayOf(now) || liveNow(now))) days.push(k); }
|
||||
let streak = 0;
|
||||
for (const k of days) { if (purchasesOn(k).some(e => e.buyerId === Number(memberId))) streak++; else break; }
|
||||
return streak;
|
||||
}
|
||||
function earnedBadge(memberId) {
|
||||
if (S.badges[memberId]) return true;
|
||||
if (fridayStreak(memberId) >= 5) { S.badges[memberId] = Date.now(); save(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// scheduled moments, checked every few minutes: Thursday 18:00 CT notice + email, Friday 09:00 CT kickoff,
|
||||
// Saturday 00:xx CT wrap-up of the Friday that just ended
|
||||
async function tick() {
|
||||
const c = cfg(); if (!c.on) return;
|
||||
const now = Date.now(); const day = dayOf(now); const wd = weekdayOf(now); const h = hourOf(now);
|
||||
const next = nextFriday(now);
|
||||
if (wd === 'Thu' && h >= 18 && next && !S.sent['thu:' + next] && await claimOnce('fri:thu:' + next)) {
|
||||
S.sent['thu:' + next] = now; save();
|
||||
const subject = 'Tomorrow is Five Dollar Friday';
|
||||
const text = 'Tomorrow is Five Dollar Friday on InstantAdPay.\n\nAny ad package of $5 or more bought on Friday (Central time) earns +' + c.pct + '% bonus credits, added the moment the purchase settles. Your $5 pays your sponsor line the second it clears, and your line’s $5s pay you. Friday is when we all push at once.\n\nLine up your pack: https://instantadpay.com/my#buy\nWatch the wave on the live ledger: https://instantadpay.com/ledger';
|
||||
await broadcast(subject, text);
|
||||
}
|
||||
if (wd === 'Fri' && h >= 9 && day >= c.start && !S.sent['kick:' + day] && await claimOnce('fri:kick:' + day)) {
|
||||
S.sent['kick:' + day] = now; save();
|
||||
X.telegram('\u{1F4B5} <b>It’s Five Dollar Friday.</b> Any ad package from $5 up earns <b>+' + c.pct + '% credits</b> today, and every pack pays its sponsor line in the same transaction. Buy yours, then watch the ledger fill up: https://instantadpay.com/my#buy').catch(() => {});
|
||||
}
|
||||
if (wd === 'Sat' && h < 3) {
|
||||
const fri = dayOf(now - 86400000);
|
||||
if (isFriday(now - 86400000) && fri >= c.start && !S.sent['wrap:' + fri] && await claimOnce('fri:wrap:' + fri)) {
|
||||
S.sent['wrap:' + fri] = now; save();
|
||||
const st = stats(fri); const top = await topLines(fri);
|
||||
X.telegram('\u{1F3C1} <b>Five Dollar Friday wrap-up</b> (' + labelOf(fri) + ')\n<b>' + st.packs + ' packs</b> from ' + st.buyers + ' buyers · $' + st.usd.toFixed(0) + ' in ad packages · <b>' + st.polPaid.toFixed(2) + ' POL</b> paid to members · ' + st.bonusCredits.toLocaleString() + ' bonus credits handed out'
|
||||
+ (top.length ? '\nBiggest lines today: ' + top.map(t => t.who + ' (' + t.packs + ')').join(', ') : '') + '\nNext one is a week away. Line it up: https://instantadpay.com/my#buy').catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
// most packs bought by a sponsor's directs on a day (the line that pushed hardest)
|
||||
async function topLines(dayKey) {
|
||||
const by = new Map();
|
||||
for (const ev of purchasesOn(dayKey)) { let sp = 0; try { const m = await X.chain.member(ev.buyerId); sp = m && m.sponsorId ? Number(m.sponsorId) : 0; } catch (e) {} if (!sp) continue; by.set(sp, (by.get(sp) || 0) + 1); }
|
||||
const rows = [...by.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3);
|
||||
const out = []; for (const [id, packs] of rows) { let who = '#' + id; try { const a = await X.accounts.byMemberId(id); if (a && a.username) who = '@' + a.username; } catch (e) {} out.push({ memberId: id, who, packs }); }
|
||||
return out;
|
||||
}
|
||||
// on-site notice + email to every active member (quiet 45+ days skipped, drip unsubscribes honoured)
|
||||
async function broadcast(subject, text) {
|
||||
let all = []; try { all = await X.accounts.listAll(5000); } catch (e) { return; }
|
||||
const now = Date.now(); let n = 0;
|
||||
for (const a of all) {
|
||||
if (!a.email) continue;
|
||||
if (now - (a.lastSeen || a.created || 0) > 45 * 86400000) continue;
|
||||
try { await X.notify(a.memberId, subject, text, 'notice'); } catch (e) {}
|
||||
try { if (X.mailer && X.mailer.hasKey() && !(X.drip && await X.drip.isUnsubscribed(a.email))) { await X.mailer.send(a.email, subject, text + '\n\nInstantAdPay'); n++; } } catch (e) {}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
module.exports = { init, cfg, view, stats, onEvent, tick, fridayStreak, earnedBadge, liveNow, nextFriday, isFriday, labelOf, topLines };
|
||||
@@ -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': 'InstantAdPay/1.0 (+https://instantadpay.com)' }, 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 };
|
||||
@@ -0,0 +1,147 @@
|
||||
// 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
|
||||
if (R.fraud && ((buyer && R.fraud.excluded(buyer)) || R.fraud.excluded(sponsor))) continue; // flagged / suspended accounts never count (2026-09-16)
|
||||
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], { log: { kind: 'bonus', note: 'Leaderboard prize: rank ' + (i + 1) + ' (' + kind + ')' } }); 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} <b>InstantAdPay</b> · ' + (kind === 'week' ? 'Weekly' : 'Monthly') + ' referral contest (from ' + when + '): <b>' + top[0].name + '</b> 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 : '')) + '\ninstantadpay.com/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 => '<table class="lb"><tr><th>#</th><th>Member</th><th>Packages</th><th>Sales</th><th>Buyers</th><th>New members</th></tr>'
|
||||
+ (v.top.length ? v.top.map(r => '<tr><td>' + r.rank + '</td><td><b>' + esc(r.name) + '</b></td><td>' + r.sales + '</td><td>$' + r.usd.toLocaleString() + '</td><td>' + r.buyers + '</td><td>' + r.joins + '</td></tr>').join('') : '<tr><td colspan="6" class="muted">No sales yet in this period.</td></tr>') + '</table>';
|
||||
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 InstantAdPay this week and this month. Weekly and monthly referral contest standings, read from the chain.';
|
||||
let h = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Leaderboard | InstantAdPay</title><meta name="description" content="' + esc(desc) + '"><link rel="canonical" href="https://instantadpay.com/leaderboard"><meta property="og:title" content="InstantAdPay leaderboard"><meta property="og:description" content="' + esc(desc) + '"><meta property="og:image" content="https://instantadpay.com/banners/iap-hero-1200x630.png"><meta name="twitter:card" content="summary_large_image">'
|
||||
+ '<link rel="icon" type="image/png" href="/logo-icon.png"><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"><link rel="stylesheet" href="/assets/site.css?v=20260913a">'
|
||||
+ '<style>.lbw{max-width:860px}.lb{width:100%;border-collapse:collapse;margin:8px 0 26px;font-variant-numeric:tabular-nums}.lb th{text-align:left;color:var(--muted);font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:8px 10px;border-bottom:1px solid var(--line)}.lb td{padding:10px;border-bottom:1px solid var(--line)}.lb tr:first-child + tr td:first-child{color:#ffd15c;font-weight:800}.prize{border-left:4px solid var(--mint);background:rgba(67,232,195,.07);padding:12px 16px;border-radius:0 12px 12px 0;margin:0 0 18px}.win{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px;margin:0 0 10px}</style></head><body><div class="wrap lbw">'
|
||||
+ '<section class="hero" style="padding:56px 0 8px"><p class="eyebrow">Referral contest</p><h1>Leaderboard: <em>who is selling</em>.</h1><p class="lead">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.</p></section>';
|
||||
h += '<h2 style="font-size:24px;margin:0 0 4px">' + week.label + ' <span class="muted small">' + fmtD(week.start) + ' to Sunday</span></h2>' + (week.prize ? '<div class="prize"><b>Weekly prizes:</b> ' + esc(week.prize) + '</div>' : '') + table(week);
|
||||
h += '<h2 style="font-size:24px;margin:0 0 4px">' + month.label + '</h2>' + (month.prize ? '<div class="prize"><b>Monthly prizes:</b> ' + esc(month.prize) + '</div>' : '') + table(month);
|
||||
h += '<h2 style="font-size:24px;margin:0 0 4px">All time</h2>' + table(all);
|
||||
const w = winners();
|
||||
if (w.length) h += '<h2 style="font-size:24px;margin:0 0 10px">Past winners</h2>' + w.slice(0, 12).map(x => '<div class="win"><b>' + (x.kind === 'week' ? 'Week of ' : 'Month of ') + fmtD(x.start) + '</b>: ' + (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) : '')) + '</div>').join('');
|
||||
h += '<p class="muted small" style="margin-top:30px">Sales are $ of packages bought by members you directly sponsor. Prizes are advertising credits or packages, never cash. No income is guaranteed.</p>';
|
||||
h += '</div><script src="/assets/common.js?v=20260914a"></script><script src="/assets/blog-page.js?v=20260914a"></script></body></html>';
|
||||
return h;
|
||||
}
|
||||
module.exports = { init, view, compute, renderPage, rolloverTick, winners, periodBounds };
|
||||
@@ -0,0 +1,111 @@
|
||||
// Money moving in and out of the receiver wallets, which the chain index cannot tell us about
|
||||
// (Marty, 2026-09-23). The P&L derives revenue from AdminPaid events and holdings from
|
||||
// eth_getBalance, so two real things were invisible:
|
||||
//
|
||||
// OUT a draw. Marty funded RM Circle #139's Culmen->Apex upgrade from Receiver A. That is a
|
||||
// legitimate use of house profit, but nothing recorded it, so "earned" and "on hand" drift
|
||||
// apart for ever. ("Why would I ever keep a receiver wallet that I never take anything out
|
||||
// of? That's stupid, so I need to be able to account for it.")
|
||||
// IN outside income paid into the same wallet, e.g. a ClickBaitPays withdrawal. Real profit,
|
||||
// but it is not an AdminPaid event, so the revenue lines never see it.
|
||||
//
|
||||
// Both are hand-entered because only Marty knows what a transfer was for. Storage:
|
||||
// DATA_DIR/ledger.json { entries: [{id, date, dir, asset, amount, usd, wallet, note, tx, ts}] }
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let FILE = null;
|
||||
const DIRS = ['in', 'out'];
|
||||
const WALLETS = ['feeA', 'feeB', 'engine', 'hunt'];
|
||||
// assets we can actually value; anything else is recorded but valued only by the usd field
|
||||
const ASSETS = ['POL', 'USDC', 'USDT', 'DAI', 'other'];
|
||||
// WHY a kind and not just a direction (Marty, 2026-09-23): three different things move through
|
||||
// Receiver A and only one of them is profit.
|
||||
// revenue outside income earned by the business, e.g. a ClickBaitPays withdrawal in POL.
|
||||
// Counts toward profit AND toward the balance.
|
||||
// draw house profit spent on something, e.g. funding RM Circle #139's upgrade. Leaves the
|
||||
// wallet, but it is not a cost of running InstantAdPay.
|
||||
// transfer Marty's own capital moved in or out for safekeeping, e.g. consolidating balances
|
||||
// from other owned positions into the Tangem receiver. Touches the balance ONLY and
|
||||
// must never show up as revenue, or the P&L reports his savings as earnings.
|
||||
const KINDS = ['revenue', 'draw', 'transfer'];
|
||||
|
||||
function init(opts) { FILE = path.join(opts.dataDir, 'ledger.json'); }
|
||||
function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (e) { return { entries: [] }; } }
|
||||
function save(d) {
|
||||
const tmp = FILE + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(d, null, 1));
|
||||
fs.renameSync(tmp, FILE); // never leave a half-written ledger behind
|
||||
}
|
||||
const newId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
const num = v => { const n = Number(v); return Number.isFinite(n) && n >= 0 ? Math.round(n * 1e6) / 1e6 : 0; };
|
||||
|
||||
function entries() {
|
||||
return load().entries.slice().sort((a, b) =>
|
||||
(b.date || '').localeCompare(a.date || '') || (b.ts || 0) - (a.ts || 0));
|
||||
}
|
||||
|
||||
function put(input) {
|
||||
const d = load();
|
||||
const dir = DIRS.includes(input.dir) ? input.dir : null;
|
||||
if (!dir) return { error: 'Say whether the money came in or went out.' };
|
||||
const amount = num(input.amount);
|
||||
const usd = num(input.usd);
|
||||
if (!amount && !usd) return { error: 'Give an amount, in POL or in dollars.' };
|
||||
const note = String(input.note || '').trim().slice(0, 200);
|
||||
if (!note) return { error: 'Say what this was for. An unexplained line is the thing we are fixing.' };
|
||||
const date = /^\d{4}-\d{2}-\d{2}$/.test(String(input.date || '')) ? input.date
|
||||
: new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }); // Central, like every other day key here
|
||||
const e = {
|
||||
id: input.id && d.entries.find(x => x.id === input.id) ? input.id : newId(),
|
||||
date, dir, amount, usd, note,
|
||||
asset: ASSETS.includes(input.asset) ? input.asset : 'POL',
|
||||
kind: KINDS.includes(input.kind) ? input.kind : (dir === 'in' ? 'revenue' : 'draw'),
|
||||
wallet: WALLETS.includes(input.wallet) ? input.wallet : 'feeA',
|
||||
tx: /^0x[0-9a-fA-F]{64}$/.test(String(input.tx || '')) ? String(input.tx).toLowerCase() : '',
|
||||
ts: Date.now(),
|
||||
};
|
||||
const i = d.entries.findIndex(x => x.id === e.id);
|
||||
if (i >= 0) d.entries[i] = e; else d.entries.push(e);
|
||||
save(d);
|
||||
return { ok: true, entry: e };
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
const d = load();
|
||||
const i = d.entries.findIndex(x => x.id === id);
|
||||
if (i < 0) return { error: 'No such entry.' };
|
||||
const [gone] = d.entries.splice(i, 1);
|
||||
save(d);
|
||||
return { ok: true, entry: gone };
|
||||
}
|
||||
|
||||
// Totals per wallet, split by direction. POL is summed separately from dollar-denominated assets
|
||||
// because only POL can be reconciled against an on-chain balance.
|
||||
function totals(wallet) {
|
||||
const rows = load().entries.filter(e => !wallet || e.wallet === wallet);
|
||||
const t = { inPol: 0, outPol: 0, inUsd: 0, outUsd: 0, count: rows.length,
|
||||
// revenue and draws are the P&L numbers; transfers move the balance and nothing else
|
||||
revenuePol: 0, revenueUsd: 0, drawPol: 0, drawUsd: 0, transferPol: 0 };
|
||||
for (const e of rows) {
|
||||
const pol = e.asset === 'POL' ? e.amount : 0;
|
||||
if (e.dir === 'in') { t.inPol += pol; t.inUsd += e.usd; } else { t.outPol += pol; t.outUsd += e.usd; }
|
||||
if (e.kind === 'revenue') { t.revenuePol += pol; t.revenueUsd += e.usd; }
|
||||
else if (e.kind === 'draw') { t.drawPol += pol; t.drawUsd += e.usd; }
|
||||
else t.transferPol += (e.dir === 'in' ? pol : -pol);
|
||||
}
|
||||
for (const k of Object.keys(t)) if (k !== 'count') t[k] = Math.round(t[k] * 1e6) / 1e6;
|
||||
return t;
|
||||
}
|
||||
|
||||
// earned (AdminPaid) + POL in - POL out = what should be sitting there. Any drift is a transfer
|
||||
// nobody has written down yet, and naming the number is the whole point of this file.
|
||||
function reconcile(earnedPol, actualPol, wallet) {
|
||||
const t = totals(wallet || 'feeA');
|
||||
const expected = Math.round((Number(earnedPol || 0) + t.inPol - t.outPol) * 1e6) / 1e6;
|
||||
const actual = Math.round(Number(actualPol || 0) * 1e6) / 1e6;
|
||||
return { earnedPol: Number(earnedPol || 0), expected, actual,
|
||||
drift: Math.round((actual - expected) * 1e6) / 1e6, totals: t };
|
||||
}
|
||||
|
||||
module.exports = { init, entries, put, remove, totals, reconcile, DIRS, ASSETS, KINDS, WALLETS };
|
||||
@@ -0,0 +1,33 @@
|
||||
// Legacy bridge: former Faucet Wave / Tier One Ads members (EvolutionScript sites Marty closed)
|
||||
// arrive via /from/<brand>; 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 };
|
||||
@@ -0,0 +1,50 @@
|
||||
// One-time claims shared by every running copy of the app (Marty, 2026-09-25).
|
||||
//
|
||||
// Why: a Coolify deploy starts the new container and only stops the old one once the new one
|
||||
// is healthy, so for half a minute two copies of this app watch the chain. On 25 September
|
||||
// both saw Marty's $5 purchase: two confirmation emails, and the Five Dollar Friday bonus
|
||||
// granted twice (+100 and +100, 19 seconds apart). Every dedupe until now lived in process
|
||||
// memory or a JSON file on the volume, neither of which a second process shares in time.
|
||||
//
|
||||
// This is the shared version: a row in MySQL that only one INSERT can win. `claim(key)` is
|
||||
// true for exactly one caller across all instances, false for everyone else. Gate anything
|
||||
// that must happen once per event, and never gate the in-memory feed (each instance keeps
|
||||
// its own).
|
||||
//
|
||||
// Without a database (local QA, the JSON store) it degrades to a per-process Set, which is
|
||||
// exactly the old behaviour: one instance, one memory.
|
||||
'use strict';
|
||||
|
||||
let db = null;
|
||||
const local = new Set();
|
||||
|
||||
function init(deps) { db = deps && deps.db; }
|
||||
|
||||
async function ensureTable() {
|
||||
if (!db || !db.enabled()) return;
|
||||
await db.q('CREATE TABLE IF NOT EXISTS event_marks (k VARCHAR(160) PRIMARY KEY, ts BIGINT NOT NULL) ENGINE=InnoDB');
|
||||
}
|
||||
|
||||
// true = this caller owns the key; false = someone (possibly another instance) already does.
|
||||
async function claim(key) {
|
||||
key = String(key).slice(0, 160);
|
||||
if (!db || !db.enabled()) { if (local.has(key)) return false; local.add(key); return true; }
|
||||
try {
|
||||
const r = await db.q('INSERT IGNORE INTO event_marks (k, ts) VALUES (?, ?)', [key, Date.now()]);
|
||||
return !!(r && r.affectedRows === 1);
|
||||
} catch (e) {
|
||||
// The table missing, or the DB unreachable, must not silently double-send: log it and
|
||||
// fall back to the per-process memory, which at least keeps ONE instance honest.
|
||||
console.error('marks: claim fell back to memory for ' + key + ': ' + e.message);
|
||||
if (local.has(key)) return false; local.add(key); return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the table from growing forever: marks older than 60 days are of no use, every event
|
||||
// they guard has long since been handled.
|
||||
async function prune() {
|
||||
if (!db || !db.enabled()) return;
|
||||
try { await db.q('DELETE FROM event_marks WHERE ts < ?', [Date.now() - 60 * 86400000]); } catch (e) {}
|
||||
}
|
||||
|
||||
module.exports = { init, ensureTable, claim, prune };
|
||||
@@ -27,32 +27,49 @@ async function lastBroadcastAt(fromEmail) {
|
||||
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) {
|
||||
//
|
||||
// `kind` separates the two things that used to share this channel:
|
||||
// 'broadcast' — a person wrote it to their team. Rare, and worth interrupting for.
|
||||
// 'alert' — the system generated it AND the member loses money by not acting: a sale
|
||||
// blocked because payouts are off, a payout that passed them by. Rare, and
|
||||
// the whole reason this channel interrupts at all.
|
||||
// 'notice' — the system generated it and nothing is required: a payout landed, a
|
||||
// purchase confirmed. These arrive constantly, so they belong in the inbox
|
||||
// and must never pop a modal. Before this split they did, which meant a
|
||||
// member with a backlog of payout notices got a fresh popup after every
|
||||
// single ad view.
|
||||
//
|
||||
// The line is not system-versus-human, it is "does this need you to do something".
|
||||
async function deliver(fromMember, fromEmail, recipients, subject, body, kind) {
|
||||
const k = ['notice', 'alert'].includes(kind) ? kind : 'broadcast';
|
||||
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]);
|
||||
await db.q("INSERT INTO sponsor_messages (from_member,from_email,to_email,subject,body,sent,kind) VALUES (?,?,?,?,?,?,?)",
|
||||
[fromMember || 0, fromEmail, to, subject, body, now, k]);
|
||||
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 });
|
||||
J.db.items.push({ id: J.db.nextId++, kind: k, fromMember: fromMember || 0, fromEmail, toEmail: to, subject, body, sent: now, readTs: 0 });
|
||||
n++;
|
||||
}
|
||||
J.save();
|
||||
}
|
||||
return n;
|
||||
}
|
||||
const isBroadcast = i => (i.kind || 'broadcast') === 'broadcast';
|
||||
// Both kinds are inbox mail; only 'chat' is the separate two-way thread.
|
||||
const isBroadcast = i => (i.kind || 'broadcast') !== 'chat';
|
||||
// ...but only a human broadcast or a money-at-stake alert may interrupt with the modal.
|
||||
const popsModal = i => ['broadcast', 'alert'].includes(i.kind || '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]);
|
||||
WHERE to_email=? AND kind<>'chat' 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 }));
|
||||
}
|
||||
@@ -77,6 +94,13 @@ async function sendChat(fromMember, fromEmail, toEmail, body) {
|
||||
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()) {
|
||||
@@ -138,7 +162,7 @@ async function threadList(email) {
|
||||
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]);
|
||||
const r = await db.q("SELECT COUNT(*) n FROM sponsor_messages WHERE to_email=? AND kind<>'chat' AND read_ts IS NULL", [e]);
|
||||
return r[0].n;
|
||||
}
|
||||
if (!J.db) J.load();
|
||||
@@ -148,15 +172,15 @@ async function unreadCount(email) {
|
||||
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]);
|
||||
const rows = await db.q(`SELECT id, from_member, subject, body, sent, kind FROM sponsor_messages
|
||||
WHERE to_email=? AND kind IN ('broadcast','alert') 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) };
|
||||
return { id: r.id, fromMember: r.from_member, subject: r.subject, body: r.body, sent: Number(r.sent), kind: r.kind };
|
||||
}
|
||||
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;
|
||||
const u = J.db.items.filter(i => i.toEmail === e && popsModal(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, kind: u.kind || 'broadcast' } : null;
|
||||
}
|
||||
async function markRead(email, id) {
|
||||
const e = String(email || '').toLowerCase();
|
||||
@@ -170,5 +194,5 @@ async function markRead(email, id) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
module.exports = { init, lastBroadcastAt, deliver, inbox, unreadCount, newestUnread, markRead,
|
||||
module.exports = { init, lastFrom, lastBroadcastAt, deliver, inbox, unreadCount, newestUnread, markRead,
|
||||
sendChat, thread, markChatRead, chatUnread, threadList };
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// The missed-payout notice, whole chain in one message (Marty, 2026-09-19).
|
||||
//
|
||||
// A purchase pays three tiers up the sponsor line. When a tier's rightful recipient is not
|
||||
// qualified, the contract passes that share up until it finds someone who is, or hands it to
|
||||
// the company. Every hop is an on-chain event: PassedUp (who was skipped, and why) and TierPaid
|
||||
// (who finally got it, and how many hops up). Until now the Telegram notice reported one tier
|
||||
// per message; this composes ONE message per purchase that shows every tier's walk, every
|
||||
// person passed, and what it added up to. The point is that the people passed over see, in
|
||||
// numbers, exactly what qualifying would have paid them.
|
||||
//
|
||||
// Pure: takes the events of one transaction plus a name resolver; returns the text or null.
|
||||
'use strict';
|
||||
|
||||
const SHARE = { 1: 0.5, 2: 0.2, 3: 0.1 }; // tier shares of the purchase, for a tier nobody caught
|
||||
const NEED = { 2: 2, 3: 5 }; // qualifying buyers a tier requires
|
||||
|
||||
function fmtPol(wei) {
|
||||
try { return (Number(BigInt(String(wei)) / 10n ** 14n) / 1e4).toFixed(4); } catch (e) { return '0'; }
|
||||
}
|
||||
|
||||
// txEvents: every indexed event sharing the tx. who(id) -> '#id @name'. had(id, tier) -> qualifying
|
||||
// buyers that member had at the time (a number) or null when unknown.
|
||||
function composeMissed(txEvents, who, had, site) {
|
||||
const buy = txEvents.find(e => e.type === 'Purchase');
|
||||
const skips = txEvents.filter(e => e.type === 'PassedUp');
|
||||
if (!buy || !skips.length) return null;
|
||||
const price = '$' + Math.round((buy.priceCents || 0) / 100);
|
||||
const lines = ['\u{1F62C} <b>Missed payouts</b> on ' + who(buy.buyerId) + "'s " + price + ' purchase:'];
|
||||
const passed = new Set(); let walked = 0;
|
||||
for (const tier of [2, 3]) {
|
||||
const sk = skips.filter(s => Number(s.tier) === tier).sort((x, y) => Number(x.li || 0) - Number(y.li || 0)); // walk order: nearest first
|
||||
if (!sk.length) continue;
|
||||
const paid = txEvents.find(e => e.type === 'TierPaid' && Number(e.tier) === tier);
|
||||
const pol = paid ? Number(fmtPol(paid.amountWei)) : Number(fmtPol(BigInt(String(buy.paidWei || 0)) * BigInt(Math.round(SHARE[tier] * 100)) / 100n));
|
||||
walked += pol;
|
||||
const hops = sk.map(s => {
|
||||
passed.add(s.skippedId);
|
||||
let why;
|
||||
if (s.reason === 'unqualified') { const n = had(s.skippedId, tier); why = (n == null ? 'not qualified' : n + ' of ' + NEED[tier] + ' qualifying buyers'); }
|
||||
else if (s.reason === 'send-failed') why = 'wallet refused the payment';
|
||||
else why = String(s.reason || 'passed');
|
||||
return who(s.skippedId) + ' (' + why + ')';
|
||||
});
|
||||
const end = paid ? 'paid to ' + who(paid.recipientId) : 'nobody above was qualified, so the company kept it';
|
||||
lines.push('Level ' + tier + ' · <b>' + pol.toFixed(4) + ' POL</b>: ' + hops.join(' → ') + ' → ' + end);
|
||||
}
|
||||
const n = passed.size;
|
||||
lines.push('<b>' + walked.toFixed(4) + ' POL walked past ' + n + ' ' + (n === 1 ? 'person' : 'people') + ' on one ' + price + ' sale.</b>');
|
||||
lines.push('Qualify before the next one: <a href="' + site + '">' + site.replace(/^https:\/\//, '') + '</a>');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
module.exports = { composeMissed, fmtPol };
|
||||
@@ -58,7 +58,8 @@ async function pushCampaign(c, opts = {}) {
|
||||
const kind = nasKind(c.type);
|
||||
if (!kind) return { skipped: 'type' };
|
||||
const budgetLeft = c.budget - (c.spent || 0);
|
||||
const assigned = Math.max(1, Math.floor(budgetLeft * impressionsPerCredit(c.type)));
|
||||
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);
|
||||
@@ -74,7 +75,8 @@ async function pushCampaign(c, opts = {}) {
|
||||
(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,?)`,
|
||||
[token, c.title || null, c.type === 'text' ? (c.body || null) : null, c.targetUrl,
|
||||
// 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://instantadpay.com/api/ads/click/' + c.id,
|
||||
assigned, assigned, now, kind.adtype,
|
||||
opts.name || 'InstantAdPay member', opts.email || 'ads@instantadpay.com',
|
||||
'InstantAdPay campaign #' + c.id, edate,
|
||||
@@ -98,14 +100,31 @@ async function readServed(nasAdId) {
|
||||
return { assigned, remaining, served, clicks: Number(rows[0].hits) || 0 };
|
||||
}
|
||||
|
||||
// stop a syndicated ad (budget spent, paused, or expired) — remaining:0 halts serving.
|
||||
// Stop a syndicated ad (budget spent, paused, or expired). NAS has no stop flag: its
|
||||
// serving query only picks rows with remaining>0, so zeroing that counter is the only
|
||||
// way to halt an ad.
|
||||
//
|
||||
// THE TRAP (found 2026-09-17): served is derived as assigned-remaining, so zeroing also
|
||||
// makes a stopped ad read back as 100% DELIVERED. That inflated every stopped campaign's
|
||||
// reported delivery, and worse, reconcileNas charges members for the difference — so
|
||||
// pausing a campaign could bill for impressions that were never served. A naturally
|
||||
// exhausted ad also ends at remaining=0, so the two cases are indistinguishable
|
||||
// afterwards. The true figure therefore has to be captured BEFORE the stop.
|
||||
async function deactivate(nasAdId) {
|
||||
if (!enabled()) return { skipped: 'flag-off' };
|
||||
let served = null;
|
||||
try { const s = await readServed(nasAdId); if (s) served = s.served; } catch (e) {}
|
||||
await q('UPDATE sponsorads SET remaining=0, EDate=NOW() WHERE ID=?', [Number(nasAdId)]);
|
||||
return { ok: true };
|
||||
return { ok: true, served }; // callers MUST persist this as the final served count
|
||||
}
|
||||
|
||||
// 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://instantadpay.com/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,
|
||||
@@ -114,4 +133,4 @@ async function topUp(nasAdId, addImpressions, days) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
module.exports = { enabled, pushCampaign, readServed, deactivate, topUp, impressionsPerCredit, nasKind };
|
||||
module.exports = { setClickUrl, enabled, pushCampaign, readServed, deactivate, topUp, impressionsPerCredit, nasKind };
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,102 @@
|
||||
// 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);
|
||||
// The drip (Marty, 2026-09-19): a code's credits arrive in STEPS installments, the first on the
|
||||
// day it is redeemed and one more on each day the member finishes their daily ad set. Whatever
|
||||
// is still unclaimed WINDOW_DAYS after redemption is never paid.
|
||||
const STEPS = 5;
|
||||
const WINDOW_DAYS = 30;
|
||||
const stepOf = credits => Math.ceil(Number(credits) / STEPS);
|
||||
|
||||
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(); },
|
||||
async forEmail(email) { return this.db.redemptions.filter(r => r.email === email); },
|
||||
async markPaid(code, email, paid, lastDay) { const r = this.db.redemptions.find(x => x.code === code && x.email === email); if (r) { r.paid = paid; r.lastDay = lastDay; this.save(); } }
|
||||
};
|
||||
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,paid,last_day,expires,ref) VALUES (?,?,?,?,?,?,?,?,?)', [r.code, r.email, r.credits, r.via, r.ts, r.paid || 0, r.lastDay || '', r.expires || 0, r.ref || null]); },
|
||||
async forEmail(email) { return (await db.q('SELECT * FROM promo_redemptions WHERE email=?', [email])).map(x => ({ code: x.code, email: x.email, credits: Number(x.credits), via: x.via, ts: Number(x.ts), paid: Number(x.paid) || 0, lastDay: x.last_day || '', expires: Number(x.expires) || 0, ref: x.ref || '' })); },
|
||||
async markPaid(code, email, paid, lastDay) { await db.q('UPDATE promo_redemptions SET paid=?, last_day=? WHERE code=? AND email=?', [paid, lastDay, code, email]); },
|
||||
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: records the redemption; the caller then pays what is due today with
|
||||
// payDue(). via = 'link' | 'dashboard'; ref = the site the ?promo= link was opened from.
|
||||
async function redeem(code, email, via, ref) {
|
||||
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);
|
||||
const ts = Date.now();
|
||||
await impl().addRedemption({ code: k, email: e, credits: c.credits, via: via || 'dashboard', ts, paid: 0, lastDay: '', expires: ts + WINDOW_DAYS * 86400000, ref: String(ref || '').slice(0, 80) });
|
||||
return { ok: true, credits: c.credits, step: stepOf(c.credits), steps: STEPS, code: k, partner: c.partner, funder: c.funder || null };
|
||||
}
|
||||
// Pay every installment that is due today for this member. pay(rec, amount, n) moves the credits
|
||||
// (and charges a funder) and returns true when it did; a false leaves the installment for another
|
||||
// day. day is the member's calendar day (Central), so one installment per day, never two.
|
||||
async function payDue(email, day, pay) {
|
||||
const e = String(email || '').toLowerCase(); const out = [];
|
||||
for (const r of await impl().forEmail(e)) {
|
||||
if (!r.expires || r.paid >= r.credits || r.lastDay === day || Date.now() > r.expires) continue;
|
||||
const c = await impl().get(r.code); if (!c) continue;
|
||||
const amount = Math.min(stepOf(r.credits), r.credits - r.paid);
|
||||
const n = Math.floor(r.paid / stepOf(r.credits)) + 1;
|
||||
if (!(await pay(Object.assign({}, r, { funder: c.funder || null, partner: c.partner }), amount, n))) continue;
|
||||
await impl().markPaid(r.code, e, r.paid + amount, day);
|
||||
out.push({ code: r.code, amount, n, of: STEPS, total: r.credits, partner: c.partner });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
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, payDue, adminView, norm, byFunder, STEPS, WINDOW_DAYS };
|
||||
@@ -1,311 +1,516 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>Admin | InstantAdPay</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
<style>
|
||||
.adm-table th,.adm-table td{padding:8px 10px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line);font-size:13.5px}
|
||||
.adm-table th{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.08em}
|
||||
.adm-table .act{white-space:nowrap}
|
||||
.adm-table .act button{margin-right:6px}
|
||||
.st{display:inline-block;border-radius:999px;padding:2px 9px;font-size:11.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}
|
||||
.st.active{background:rgba(67,232,195,.14);color:var(--mint)}
|
||||
.st.paused{background:rgba(255,178,56,.14);color:var(--amber)}
|
||||
.st.out{background:rgba(255,255,255,.08);color:var(--muted)}
|
||||
.house-tag{display:inline-block;border:1px solid rgba(157,125,255,.5);color:var(--violet);border-radius:6px;padding:1px 6px;font-size:10.5px;font-weight:700;margin-left:6px;vertical-align:middle}
|
||||
.adm-form p{margin:0 0 10px}
|
||||
.adm-form label.small{display:block;margin-bottom:4px}
|
||||
.trunc{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:bottom}
|
||||
textarea.json{font-family:var(--mono);font-size:12.5px;width:100%;min-height:260px}
|
||||
.ph-chips{display:inline-flex;gap:6px;flex-wrap:wrap;margin-left:6px;vertical-align:middle}
|
||||
.ph-chips .chip-t{padding:2px 10px;font-size:11.5px;font-family:var(--mono);text-transform:none;letter-spacing:0;cursor:pointer;background:transparent}
|
||||
.drip-steps{display:grid;gap:12px}
|
||||
.drip-step{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:rgba(4,8,7,.4)}
|
||||
.drip-step .ds-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px}
|
||||
.drip-step .ds-n{font-family:var(--mono);font-size:12px;color:var(--mint);letter-spacing:.08em}
|
||||
.drip-step .ds-when{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--muted)}
|
||||
.drip-step .ds-when input{width:80px;text-align:right}
|
||||
.drip-step .ds-when b{color:var(--ink)}
|
||||
.drip-step .ds-tools{margin-left:auto;display:flex;gap:6px;flex-wrap:wrap}
|
||||
.drip-step .ds-tools .btn{padding:6px 12px;font-size:12px}
|
||||
.drip-step input.ds-subject{width:100%;margin-bottom:8px;font-weight:700}
|
||||
.drip-step textarea.ds-body{width:100%;min-height:200px;font-size:13.5px;line-height:1.5}
|
||||
.rates-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px 22px}
|
||||
.rates-form .rf{display:flex;flex-direction:column;gap:4px}
|
||||
.rates-form .rf label{font-size:13px;font-weight:700}
|
||||
.rates-form .rf .hint{font-size:11.5px;color:var(--muted)}
|
||||
.rates-form .rf input[type=number]{max-width:160px}
|
||||
.rates-form .rf.wide{grid-column:1/-1}
|
||||
.rates-form .sub-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px}
|
||||
.rates-form .sub-grid label{font-size:12px;font-weight:600;color:var(--muted);display:flex;flex-direction:column;gap:3px}
|
||||
.rates-form table.tiers{width:auto;font-size:13px}
|
||||
.rates-form table.tiers td,.rates-form table.tiers th{padding:4px 8px;border:0}
|
||||
.rates-form table.tiers input{width:90px}
|
||||
.kv-form{display:grid;gap:8px}
|
||||
.kv-row{display:grid;grid-template-columns:220px 1fr auto;gap:10px;align-items:center}
|
||||
.kv-row .k{font-family:var(--mono);font-size:12.5px;overflow-wrap:anywhere}
|
||||
.kv-row input[type=checkbox]{width:auto;justify-self:start}
|
||||
@media(max-width:700px){.kv-row{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bo-body">
|
||||
|
||||
<!-- signed-out: admin sign-in -->
|
||||
<div id="authArea">
|
||||
<div class="wrap" style="max-width:520px">
|
||||
<section class="hero" style="padding:64px 0 10px;text-align:left">
|
||||
<p class="eyebrow">InstantAdPay</p>
|
||||
<h1 style="font-size:34px">Admin <em>sign in</em></h1>
|
||||
<p class="lead" style="font-size:16px">Only the admin address can sign in here. A one-time code goes to that inbox.</p>
|
||||
</section>
|
||||
<div class="card">
|
||||
<p><input id="adEmail" type="email" placeholder="Admin email" autocomplete="email" style="width:100%"></p>
|
||||
<p id="adCodeRow" hidden><input id="adCode" inputmode="numeric" placeholder="6-digit code from your email" style="width:100%"></p>
|
||||
<p id="adErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<p style="margin:0;display:flex;gap:10px">
|
||||
<button class="btn" id="adSend" type="button">Send code</button>
|
||||
<button class="btn" id="adVerify" type="button" hidden>Sign in</button>
|
||||
</p>
|
||||
<p class="small muted" style="margin:14px 0 0"><a href="/my">Back to the member area</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- signed-in: admin shell -->
|
||||
<div id="adminArea" class="bo" hidden>
|
||||
<aside class="bo-side" id="boSide">
|
||||
<a class="logo" href="/"><img src="/logo.png" alt="InstantAdPay" style="height:32px;display:block"></a>
|
||||
<span class="byline">Brought to you by the <b>Crypto Team Build Network</b></span>
|
||||
<nav class="bo-menu" aria-label="Admin menu">
|
||||
<button data-pane="overview" class="on" type="button"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="8" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/><rect x="13" y="13" width="8" height="8" rx="2"/></svg>Overview</button>
|
||||
<button data-pane="house" type="button"><svg viewBox="0 0 24 24"><path d="M3 11l14-5v12L3 13v-2z"/><path d="M17 8a4 4 0 0 1 0 8M7 13v5a2 2 0 0 0 4 0v-3"/></svg>House ads</button>
|
||||
<button data-pane="campaigns" type="button"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>All campaigns</button>
|
||||
<button data-pane="members" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3.2"/><circle cx="5" cy="17" r="2.6"/><circle cx="19" cy="17" r="2.6"/><path d="M12 10v3M12 13l-5 2M12 13l5 2"/></svg>Members</button>
|
||||
<button data-pane="reports" type="button"><svg viewBox="0 0 24 24"><path d="M12 3l9 16H3z"/><path d="M12 10v4M12 17v.5"/></svg>Reports<span class="pill" id="repBadge" hidden></span></button>
|
||||
<button data-pane="pnl" type="button"><svg viewBox="0 0 24 24"><path d="M4 19V5M4 19h16"/><path d="M8 15l3-4 3 2 5-6"/></svg>P&L</button>
|
||||
<button data-pane="settings" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>Settings</button>
|
||||
</nav>
|
||||
<div class="bo-links">
|
||||
<span class="bo-cap">Site</span>
|
||||
<a href="/my">Member area</a>
|
||||
<a href="/ledger" target="_blank" rel="noopener">Live ledger</a>
|
||||
<a href="/" target="_blank" rel="noopener">Home page</a>
|
||||
</div>
|
||||
<div class="bo-foot">
|
||||
<span id="adWho" class="small muted">…</span>
|
||||
<a href="#" id="adLogout" class="small">Log out</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="bo-main">
|
||||
<header class="bo-top">
|
||||
<button id="boBurger" aria-label="Menu" type="button">☰</button>
|
||||
<h2 id="boTitle" style="margin:0">Overview</h2>
|
||||
<span class="small muted" id="chainLine"></span>
|
||||
</header>
|
||||
<main class="bo-content">
|
||||
|
||||
<div class="pane" id="pane-overview">
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));margin-top:0">
|
||||
<div class="statx"><div><div class="nv" id="ovAccounts">0</div><div class="lb">Accounts</div></div></div>
|
||||
<div class="statx c-cyan"><div><div class="nv" id="ovMembers">0</div><div class="lb">On-chain members</div></div></div>
|
||||
<div class="statx c-violet"><div><div class="nv" id="ovActive">0</div><div class="lb">Active campaigns</div><span class="chip flat" id="ovCampSub"></span></div></div>
|
||||
<div class="statx c-amber"><div><div class="nv" id="ovReports">0</div><div class="lb">Open reports</div><span class="chip flat" id="ovBurnSub"></span></div></div>
|
||||
<div class="statx"><div><div class="nv" id="ovDrips">0</div><div class="lb">Follow-ups in flight</div><span class="chip flat" id="ovDripSub"></span></div></div>
|
||||
</div>
|
||||
<div class="grid c2" style="margin-top:16px">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Campaigns by format</h3><span class="sub">all owners</span></div>
|
||||
<div id="ovByType" class="small muted">…</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Chain</h3><span class="sub">live config</span></div>
|
||||
<div id="ovChain" class="small">…</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Quick actions</h3></div>
|
||||
<p style="display:flex;gap:10px;flex-wrap:wrap;margin:0">
|
||||
<button class="btn small" type="button" data-goto="house">Place a house ad</button>
|
||||
<button class="btn small sec" type="button" data-goto="campaigns">Review campaigns</button>
|
||||
<button class="btn small sec" type="button" data-goto="members">Members</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-house" hidden>
|
||||
<div class="card adm-form">
|
||||
<div class="card-head"><h3>New house ad</h3><span class="sub">free: nothing is charged, the budget is only a delivery cap</span></div>
|
||||
<div class="grid c3">
|
||||
<p><select id="hType" style="width:100%">
|
||||
<option value="banner">Banner (per impression)</option>
|
||||
<option value="text">Text ad (per impression)</option>
|
||||
<option value="login">Login ad (full-screen after sign-in)</option>
|
||||
<option value="solo">Solo ad (inbox delivery)</option>
|
||||
<option value="video">Video ad (watch to earn)</option>
|
||||
<option value="featured">Featured link (rotation)</option>
|
||||
<option value="visits">Verified visits</option>
|
||||
</select></p>
|
||||
<p><input id="hName" placeholder="Campaign name" style="width:100%"></p>
|
||||
<p><input id="hBudget" type="number" min="10" placeholder="Delivery cap in credits (default 100,000)" style="width:100%"></p>
|
||||
</div>
|
||||
<p><input id="hTarget" placeholder="Target URL (https://…)" autocomplete="off" style="width:100%"></p>
|
||||
<p class="small muted" style="margin-top:-4px">Banner and text targets are framed in the ad viewer, so they must allow framing (checked on submit).</p>
|
||||
<div id="hBannerRow">
|
||||
<p><input id="hImage" placeholder="Image URL (https://… or /uploads/…)" autocomplete="off" style="width:100%"></p>
|
||||
<p><input type="file" id="hImageFile" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
|
||||
<button type="button" class="btn small sec" id="hImageUploadBtn">Upload image</button>
|
||||
<span id="hImageInfo" class="small muted"></span></p>
|
||||
<p><select id="hSize" style="width:100%"></select></p>
|
||||
</div>
|
||||
<div id="hTextRow" hidden>
|
||||
<p><input id="hTitle" maxlength="60" placeholder="Headline (max 60)" style="width:100%"></p>
|
||||
<p><input id="hBody" maxlength="140" placeholder="Ad text (max 140)" style="width:100%"></p>
|
||||
</div>
|
||||
<div id="hSoloRow" hidden>
|
||||
<p><input id="hSoloTitle" maxlength="80" placeholder="Subject line (max 80)" style="width:100%"></p>
|
||||
<p><textarea id="hSoloBody" rows="8" placeholder="Message body. Simple HTML is fine (bold, lists, links, images)." style="width:100%"></textarea></p>
|
||||
<p><input id="hSoloCta" maxlength="30" placeholder="Call-to-action label (default: Learn more)" style="width:100%"></p>
|
||||
<p class="small muted" id="hSoloHint"></p>
|
||||
</div>
|
||||
<div id="hVideoRow" hidden>
|
||||
<p><input id="hVideoUrl" placeholder="Video URL (direct https link ending .mp4 or .webm, or /uploads/…)" autocomplete="off" style="width:100%"></p>
|
||||
<p><input type="file" id="hVideoFile" accept="video/mp4,video/webm" hidden>
|
||||
<button type="button" class="btn small sec" id="hVideoUploadBtn">Upload video</button>
|
||||
<span id="hVideoInfo" class="small muted"></span></p>
|
||||
<p><input id="hVideoTitle" maxlength="80" placeholder="Video title (optional)" style="width:100%"></p>
|
||||
<p><select id="hWatchSecs" style="width:100%"></select></p>
|
||||
<p><input id="hVideoCta" maxlength="30" placeholder="Call-to-action label (default: Learn more)" style="width:100%"></p>
|
||||
</div>
|
||||
<div id="hFeatRow" hidden>
|
||||
<p><input id="hFeatTitle" maxlength="70" placeholder="Headline for the featured link" style="width:100%"></p>
|
||||
<div class="grid c2">
|
||||
<p><select id="hFeatDays" style="width:100%"></select></p>
|
||||
<p><input id="hFeatStart" type="number" min="0" value="0" placeholder="Start in N days (0 = today)" style="width:100%"></p>
|
||||
</div>
|
||||
<p class="small muted" id="hFeatHint"></p>
|
||||
</div>
|
||||
<div id="hVisitsRow" hidden>
|
||||
<p><input id="hVisitTitle" maxlength="80" placeholder="Headline (what members see before visiting)" style="width:100%"></p>
|
||||
<p><input id="hVisitCount" type="number" min="20" placeholder="How many verified visits?" style="width:100%"></p>
|
||||
<p class="small muted" id="hVisitHint"></p>
|
||||
</div>
|
||||
<p id="hErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<button class="btn" id="hCreate" type="button">Place house ad</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>House ads</h3><span class="sub" id="houseSub">running free</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="houseTable"></table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Wall fallback ads</h3><span class="sub" id="wallAdsSub">shown on member walls in positions they have not earned or filled, when no upline banner exists</span></div>
|
||||
<p class="small muted">A member's wall has three positions. Position 1 is theirs. Positions 2 and 3 show an upline's banner until the member earns them (2 and 5 qualifying buyers); when there is no upline banner, one of these ads shows instead. They rotate in order across walls. Leave the list empty to fall back to a plain InstantAdPay card.</p>
|
||||
<div id="wallAdsList" class="drip-steps"></div>
|
||||
<p style="display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 0">
|
||||
<button class="btn small" id="wallAdsSave" type="button">Save wall ads</button>
|
||||
<button class="btn small sec" id="wallAdsAdd" type="button">+ Add a wall ad</button>
|
||||
</p>
|
||||
<p id="wallAdsErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-campaigns" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>All campaigns</h3><span class="sub" id="campSub">newest first</span></div>
|
||||
<p style="margin:0 0 10px"><input id="campFilter" placeholder="Filter by owner, name, type or status" style="width:100%"></p>
|
||||
<div class="tablewrap"><table class="adm-table" id="campTable"></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-members" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Members</h3><span class="sub" id="memSub">newest first</span></div>
|
||||
<p style="margin:0 0 10px"><input id="memFilter" placeholder="Filter by email, username, member # or sponsor" style="width:100%"></p>
|
||||
<p class="small muted">Sponsor = the token the account joined under (username, share code, or member #). Editing it re-points free referrals and future purchases. On-chain sponsorship is permanent once activated.</p>
|
||||
<div class="tablewrap"><table class="adm-table" id="memTable"></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-reports" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Ad reports</h3><span class="sub">members flagging ads</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="repTable"></table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Pending burns</h3><span class="sub">on-chain credit burns waiting to be executed</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="burnTable"></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-pnl" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Profit and loss</h3><span class="sub">read from the chain index; periods are by block (about 43,200 Polygon blocks a day)</span></div>
|
||||
<div class="chips" id="pnlPeriods"><button type="button" class="chip-t" data-days="7">7 days</button><button type="button" class="chip-t on" data-days="30">30 days</button><button type="button" class="chip-t" data-days="90">90 days</button><button type="button" class="chip-t" data-days="0">All time</button></div>
|
||||
<div class="grid" id="pnlTiles" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));margin-top:12px"></div>
|
||||
</div>
|
||||
<div class="grid c2">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Where the money went</h3><span class="sub">per period</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="pnlSplit"></table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Wallets</h3><span class="sub">live balances</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="pnlWallets"></table></div>
|
||||
<p class="small muted" style="margin:10px 0 0">Fixed monthly cost (USD) for the net line: <input type="number" id="pnlFixed" min="0" step="1" style="width:110px"> <button class="btn small sec" type="button" id="pnlFixedSave">Save</button></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Automatic credit burner</h3><span class="sub">settles pending campaign spend on-chain from the engine wallet</span></div>
|
||||
<p class="small" id="burnerLine">…</p>
|
||||
<p><button class="btn small sec" type="button" id="burnerRun">Run now</button></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane" id="pane-settings" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Follow-up emails</h3><span class="sub" id="dripSub">sent to every new free member, hours after sign-up</span></div>
|
||||
<p class="small muted">One card per email. Placeholders fill in per reader: click a chip to drop it where your cursor is.
|
||||
<span class="ph-chips" id="phChips">
|
||||
<button type="button" class="chip-t" data-ph="{{link}}" title="the reader's own invite link">{{link}}</button>
|
||||
<button type="button" class="chip-t" data-ph="{{sponsor}}" title="their sponsor's name">{{sponsor}}</button>
|
||||
<button type="button" class="chip-t" data-ph="{{site}}" title="https://instantadpay.com">{{site}}</button>
|
||||
<button type="button" class="chip-t" data-ph="{{paid:words if they bought|words if they have not}}" title="two versions: bought / not yet">{{paid:…|…}}</button>
|
||||
<button type="button" class="chip-t" data-ph="{{footer}}" title="unsubscribe line + disclaimer (keep it at the end)">{{footer}}</button>
|
||||
</span></p>
|
||||
<div id="dripSteps" class="drip-steps"></div>
|
||||
<p id="dripErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<p style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:14px 0 0">
|
||||
<button class="btn small" id="dripSave" type="button">Save sequence</button>
|
||||
<button class="btn small sec" id="dripAdd" type="button">+ Add an email</button>
|
||||
<button class="btn small sec" id="dripReset" type="button">Reset to defaults</button>
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Rates</h3><span class="sub">credit prices, rewards and limits</span></div>
|
||||
<div id="ratesForm" class="rates-form"></div>
|
||||
<p id="ratesErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<p style="margin:14px 0 0"><button class="btn small" id="ratesSave" type="button">Save rates</button></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Site settings</h3><span class="sub">public: everything here is served to the browser, never put a secret in it</span></div>
|
||||
<div id="siteForm" class="kv-form"></div>
|
||||
<p style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:12px 0 0">
|
||||
<input id="siteNewKey" placeholder="new setting name" style="max-width:220px">
|
||||
<button class="btn small sec" id="siteAddKey" type="button">+ Add setting</button>
|
||||
</p>
|
||||
<p id="siteErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<p style="margin:14px 0 0"><button class="btn small" id="siteSave" type="button">Save site settings</button></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/common.js?v=20260910c"></script>
|
||||
<script src="/assets/admin.js?v=20260910c"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>Admin | InstantAdPay</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
<style>
|
||||
.adm-table{width:100%;table-layout:auto}
|
||||
.adm-table th{cursor:pointer;user-select:none;white-space:nowrap}
|
||||
#trfSources th,#trfAngles th{white-space:normal;text-align:center;line-height:1.2;vertical-align:bottom}
|
||||
#trfSources td:not(:first-child),#trfAngles td:not(:first-child){text-align:center;font-variant-numeric:tabular-nums}
|
||||
.adm-table th.sort-asc::after{content:' \25B2';font-size:9px;color:var(--mint)}
|
||||
.adm-table th.sort-desc::after{content:' \25BC';font-size:9px;color:var(--mint)}
|
||||
.adm-table.kv th{cursor:default;color:var(--muted);font-weight:500}
|
||||
.adm-table.kv th.sort-asc::after,.adm-table.kv th.sort-desc::after{content:''}
|
||||
.adm-table td.act,.adm-table th:last-child{width:1%;white-space:nowrap} /* the action column keeps its full width; text columns give way */
|
||||
.adm-table td:first-child{overflow-wrap:anywhere;word-break:break-word}
|
||||
.adm-table th,.adm-table td{padding:8px 10px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line);font-size:13.5px}
|
||||
.adm-table td.when{white-space:nowrap}
|
||||
.adm-table th{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.08em}
|
||||
.adm-table .act{white-space:nowrap}
|
||||
.adm-table .act button{margin-right:6px}
|
||||
.st{display:inline-block;border-radius:999px;padding:2px 9px;font-size:11.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}
|
||||
.st.active{background:rgba(67,232,195,.14);color:var(--mint)}
|
||||
.st.paused{background:rgba(255,178,56,.14);color:var(--amber)}
|
||||
.st.out{background:rgba(255,255,255,.08);color:var(--muted)}
|
||||
.house-tag{display:inline-block;border:1px solid rgba(157,125,255,.5);color:var(--violet);border-radius:6px;padding:1px 6px;font-size:10.5px;font-weight:700;margin-left:6px;vertical-align:middle}
|
||||
.adm-form p{margin:0 0 10px}
|
||||
.adm-form label.small{display:block;margin-bottom:4px}
|
||||
.trunc{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:bottom}
|
||||
textarea.json{font-family:var(--mono);font-size:12.5px;width:100%;min-height:260px}
|
||||
.ph-chips{display:inline-flex;gap:6px;flex-wrap:wrap;margin-left:6px;vertical-align:middle}
|
||||
.ph-chips .chip-t{padding:2px 10px;font-size:11.5px;font-family:var(--mono);text-transform:none;letter-spacing:0;cursor:pointer;background:transparent}
|
||||
.drip-steps{display:grid;gap:12px}
|
||||
.drip-step{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:rgba(4,8,7,.4)}
|
||||
.drip-step .ds-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px}
|
||||
.drip-step .ds-n{font-family:var(--mono);font-size:12px;color:var(--mint);letter-spacing:.08em}
|
||||
.drip-step .ds-when{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--muted)}
|
||||
.drip-step .ds-when input{width:80px;text-align:right}
|
||||
.drip-step .ds-when b{color:var(--ink)}
|
||||
.drip-step .ds-tools{margin-left:auto;display:flex;gap:6px;flex-wrap:wrap}
|
||||
.drip-step .ds-tools .btn{padding:6px 12px;font-size:12px}
|
||||
.drip-step input.ds-subject{width:100%;margin-bottom:8px;font-weight:700}
|
||||
.drip-step textarea.ds-body{width:100%;min-height:200px;font-size:13.5px;line-height:1.5}
|
||||
.rates-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px 22px}
|
||||
.rates-form .rf{display:flex;flex-direction:column;gap:4px}
|
||||
.rates-form .rf label{font-size:13px;font-weight:700}
|
||||
.rates-form .rf .hint{font-size:11.5px;color:var(--muted)}
|
||||
.rates-form .rf input[type=number]{max-width:160px}
|
||||
.rates-form .rf.wide{grid-column:1/-1}
|
||||
.rates-form .sub-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px}
|
||||
.rates-form .sub-grid label{font-size:12px;font-weight:600;color:var(--muted);display:flex;flex-direction:column;gap:3px}
|
||||
.rates-form table.tiers{width:auto;font-size:13px}
|
||||
.rates-form table.tiers td,.rates-form table.tiers th{padding:4px 8px;border:0}
|
||||
.rates-form table.tiers input{width:90px}
|
||||
.kv-form{display:grid;gap:8px}
|
||||
.kv-row{display:grid;grid-template-columns:220px 1fr auto;gap:10px;align-items:center}
|
||||
.kv-row .k{font-family:var(--mono);font-size:12.5px;overflow-wrap:anywhere}
|
||||
.kv-row input[type=checkbox]{width:auto;justify-self:start}
|
||||
@media(max-width:700px){.kv-row{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bo-body">
|
||||
|
||||
<!-- signed-out: admin sign-in -->
|
||||
<div id="authArea">
|
||||
<div class="wrap" style="max-width:520px">
|
||||
<section class="hero" style="padding:64px 0 10px;text-align:left">
|
||||
<p class="eyebrow">InstantAdPay</p>
|
||||
<h1 style="font-size:34px">Admin <em>sign in</em></h1>
|
||||
<p class="lead" style="font-size:16px">Only the admin address can sign in here. A one-time code goes to that inbox.</p>
|
||||
</section>
|
||||
<div class="card">
|
||||
<p><input id="adEmail" type="email" placeholder="Admin email" autocomplete="email" style="width:100%"></p>
|
||||
<p id="adCodeRow" hidden><input id="adCode" inputmode="numeric" placeholder="6-digit code from your email" style="width:100%"></p>
|
||||
<p id="adErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<p style="margin:0;display:flex;gap:10px">
|
||||
<button class="btn" id="adSend" type="button">Send code</button>
|
||||
<button class="btn" id="adVerify" type="button" hidden>Sign in</button>
|
||||
</p>
|
||||
<p class="small muted" style="margin:14px 0 0"><a href="/my">Back to the member area</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- signed-in: admin shell -->
|
||||
<div id="adminArea" class="bo" hidden>
|
||||
<aside class="bo-side" id="boSide">
|
||||
<a class="logo" href="/"><img src="/logo.png" alt="InstantAdPay" style="height:32px;display:block"></a>
|
||||
<span class="byline">Brought to you by the <b>Crypto Team Build Network</b></span>
|
||||
<nav class="bo-menu" aria-label="Admin menu">
|
||||
<button data-pane="overview" class="on" type="button"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="8" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/><rect x="13" y="13" width="8" height="8" rx="2"/></svg>Overview</button>
|
||||
<button data-pane="house" type="button"><svg viewBox="0 0 24 24"><path d="M3 11l14-5v12L3 13v-2z"/><path d="M17 8a4 4 0 0 1 0 8M7 13v5a2 2 0 0 0 4 0v-3"/></svg>House ads</button>
|
||||
<button data-pane="campaigns" type="button"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>All campaigns</button>
|
||||
<button data-pane="members" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3.2"/><circle cx="5" cy="17" r="2.6"/><circle cx="19" cy="17" r="2.6"/><path d="M12 10v3M12 13l-5 2M12 13l5 2"/></svg>Members</button>
|
||||
<button data-pane="reports" type="button"><svg viewBox="0 0 24 24"><path d="M12 3l9 16H3z"/><path d="M12 10v4M12 17v.5"/></svg>Reports<span class="pill" id="repBadge" hidden></span></button>
|
||||
<button data-pane="traffic" type="button"><svg viewBox="0 0 24 24"><path d="M3 17l6-6 4 4 8-8"/><path d="M14 7h7v7"/></svg>Traffic</button>
|
||||
<button data-pane="blog" type="button"><svg viewBox="0 0 24 24"><path d="M4 4h12l4 4v12H4z"/><path d="M8 12h8M8 16h8M8 8h4"/></svg>Blog</button>
|
||||
<button data-pane="releases" type="button"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h10M4 18h7"/><circle cx="18" cy="17" r="3"/></svg>Releases</button>
|
||||
<button data-pane="pnl" type="button"><svg viewBox="0 0 24 24"><path d="M4 19V5M4 19h16"/><path d="M8 15l3-4 3 2 5-6"/></svg>P&L</button>
|
||||
<button data-pane="settings" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>Settings</button>
|
||||
</nav>
|
||||
<div class="bo-links">
|
||||
<span class="bo-cap">Site</span>
|
||||
<a href="/my">Member area</a>
|
||||
<a href="/ledger" target="_blank" rel="noopener">Live ledger</a>
|
||||
<a href="/" target="_blank" rel="noopener">Home page</a>
|
||||
</div>
|
||||
<div class="bo-foot">
|
||||
<span id="adWho" class="small muted">…</span>
|
||||
<a href="#" id="adLogout" class="small">Log out</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="bo-main">
|
||||
<header class="bo-top">
|
||||
<button id="boBurger" aria-label="Menu" type="button">☰</button>
|
||||
<h2 id="boTitle" style="margin:0">Overview</h2>
|
||||
<span class="small muted" id="chainLine"></span>
|
||||
</header>
|
||||
<main class="bo-content">
|
||||
|
||||
<div class="pane" id="pane-overview">
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));margin-top:0">
|
||||
<div class="statx"><div><div class="nv" id="ovAccounts">0</div><div class="lb">Accounts</div></div></div>
|
||||
<div class="statx c-cyan"><div><div class="nv" id="ovMembers">0</div><div class="lb">On-chain members</div></div></div>
|
||||
<div class="statx c-violet"><div><div class="nv" id="ovActive">0</div><div class="lb">Active campaigns</div><span class="chip flat" id="ovCampSub"></span></div></div>
|
||||
<div class="statx c-amber"><div><div class="nv" id="ovReports">0</div><div class="lb">Open reports</div><span class="chip flat" id="ovBurnSub"></span></div></div>
|
||||
<div class="statx"><div><div class="nv" id="ovDrips">0</div><div class="lb">Follow-ups in flight</div><span class="chip flat" id="ovDripSub"></span></div></div>
|
||||
</div>
|
||||
<div class="grid c2" style="margin-top:16px">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Campaigns by format</h3><span class="sub">all owners</span></div>
|
||||
<div id="ovByType" class="small muted">…</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Chain</h3><span class="sub">live config</span></div>
|
||||
<div id="ovChain" class="small">…</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Quick actions</h3></div>
|
||||
<p style="display:flex;gap:10px;flex-wrap:wrap;margin:0">
|
||||
<button class="btn small" type="button" data-goto="house">Place a house ad</button>
|
||||
<button class="btn small sec" type="button" data-goto="campaigns">Review campaigns</button>
|
||||
<button class="btn small sec" type="button" data-goto="members">Members</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-house" hidden>
|
||||
<div class="card adm-form">
|
||||
<div class="card-head"><h3>New house ad</h3><span class="sub">free: nothing is charged, the budget is only a delivery cap</span></div>
|
||||
<div class="grid c3">
|
||||
<p><select id="hType" style="width:100%">
|
||||
<option value="banner">Banner (per impression)</option>
|
||||
<option value="text">Text ad (per impression)</option>
|
||||
<option value="login">Login ad (full-screen after sign-in)</option>
|
||||
<option value="solo">Solo ad (inbox delivery)</option>
|
||||
<option value="video">Video ad (watch to earn)</option>
|
||||
<option value="featured">Featured link (rotation)</option>
|
||||
<option value="visits">Verified visits</option>
|
||||
</select></p>
|
||||
<p><input id="hName" placeholder="Campaign name" style="width:100%"></p>
|
||||
<p><input id="hBudget" type="number" min="10" placeholder="Delivery cap in credits (default 100,000)" style="width:100%"></p>
|
||||
</div>
|
||||
<p><input id="hTarget" placeholder="Target URL (https://…)" autocomplete="off" style="width:100%"></p>
|
||||
<p class="small muted" style="margin-top:-4px">Banner and text targets are framed in the ad viewer, so they must allow framing (checked on submit).</p>
|
||||
<div id="hBannerRow">
|
||||
<p><input id="hImage" placeholder="Image URL (https://… or /uploads/…)" autocomplete="off" style="width:100%"></p>
|
||||
<p><input type="file" id="hImageFile" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
|
||||
<button type="button" class="btn small sec" id="hImageUploadBtn">Upload image</button>
|
||||
<span id="hImageInfo" class="small muted"></span></p>
|
||||
<p><select id="hSize" style="width:100%"></select></p>
|
||||
</div>
|
||||
<div id="hTextRow" hidden>
|
||||
<p><input id="hTitle" maxlength="60" placeholder="Headline (max 60)" style="width:100%"></p>
|
||||
<p><input id="hBody" maxlength="140" placeholder="Ad text (max 140)" style="width:100%"></p>
|
||||
</div>
|
||||
<div id="hSoloRow" hidden>
|
||||
<p><input id="hSoloTitle" maxlength="80" placeholder="Subject line (max 80)" style="width:100%"></p>
|
||||
<p><textarea id="hSoloBody" rows="8" placeholder="Message body. Simple HTML is fine (bold, lists, links, images)." style="width:100%"></textarea></p>
|
||||
<p><input id="hSoloCta" maxlength="30" placeholder="Call-to-action label (default: Learn more)" style="width:100%"></p>
|
||||
<p class="small muted" id="hSoloHint"></p>
|
||||
</div>
|
||||
<div id="hVideoRow" hidden>
|
||||
<p><input id="hVideoUrl" placeholder="Video URL (direct https link ending .mp4 or .webm, or /uploads/…)" autocomplete="off" style="width:100%"></p>
|
||||
<p><input type="file" id="hVideoFile" accept="video/mp4,video/webm" hidden>
|
||||
<button type="button" class="btn small sec" id="hVideoUploadBtn">Upload video</button>
|
||||
<span id="hVideoInfo" class="small muted"></span></p>
|
||||
<p><input id="hVideoTitle" maxlength="80" placeholder="Video title (optional)" style="width:100%"></p>
|
||||
<p><select id="hWatchSecs" style="width:100%"></select></p>
|
||||
<p><input id="hVideoCta" maxlength="30" placeholder="Call-to-action label (default: Learn more)" style="width:100%"></p>
|
||||
</div>
|
||||
<div id="hFeatRow" hidden>
|
||||
<p><input id="hFeatTitle" maxlength="70" placeholder="Headline for the featured link" style="width:100%"></p>
|
||||
<div class="grid c2">
|
||||
<p><select id="hFeatDays" style="width:100%"></select></p>
|
||||
<p><input id="hFeatStart" type="number" min="0" value="0" placeholder="Start in N days (0 = today)" style="width:100%"></p>
|
||||
</div>
|
||||
<p class="small muted" id="hFeatHint"></p>
|
||||
</div>
|
||||
<div id="hVisitsRow" hidden>
|
||||
<p><input id="hVisitTitle" maxlength="80" placeholder="Headline (what members see before visiting)" style="width:100%"></p>
|
||||
<p><input id="hVisitCount" type="number" min="20" placeholder="How many verified visits?" style="width:100%"></p>
|
||||
<p class="small muted" id="hVisitHint"></p>
|
||||
</div>
|
||||
<p id="hErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<button class="btn" id="hCreate" type="button">Place house ad</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>House ads</h3><span class="sub" id="houseSub">running free</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="houseTable"></table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Wall fallback ads</h3><span class="sub" id="wallAdsSub">shown on member walls in positions they have not earned or filled, when no upline banner exists</span></div>
|
||||
<p class="small muted">A member's wall has three positions. Position 1 is theirs. Positions 2 and 3 show an upline's banner until the member earns them (2 and 5 qualifying buyers); when there is no upline banner, one of these ads shows instead. They rotate in order across walls. Leave the list empty to fall back to a plain InstantAdPay card.</p>
|
||||
<div id="wallAdsList" class="drip-steps"></div>
|
||||
<p style="display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 0">
|
||||
<button class="btn small" id="wallAdsSave" type="button">Save wall ads</button>
|
||||
<button class="btn small sec" id="wallAdsAdd" type="button">+ Add a wall ad</button>
|
||||
</p>
|
||||
<p id="wallAdsErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-campaigns" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>All campaigns</h3><span class="sub" id="campSub">newest first</span></div>
|
||||
<p style="margin:0 0 10px"><input id="campFilter" placeholder="Filter by owner, name, type or status" style="width:100%"></p>
|
||||
<div class="tablewrap"><table class="adm-table" id="campTable"></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-members" hidden>
|
||||
<div class="card" id="memSearchCard">
|
||||
<div class="card-head"><h3>Find a member</h3><span class="sub">matches as you type; Enter opens the first match</span></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><input id="memSearch" autocomplete="off" placeholder="Start typing a name, username, email, member # or wallet…" style="flex:1;min-width:240px"><button type="button" class="btn small" id="memOpen">Open</button></div>
|
||||
<div id="memHits" hidden style="margin-top:6px;border:1px solid var(--line);border-radius:10px;overflow:hidden"></div>
|
||||
<p class="small" id="memSearchMsg" hidden style="margin:8px 0 0"></p>
|
||||
</div>
|
||||
<div class="card" id="memCard" hidden>
|
||||
<div class="card-head"><h3 id="mcName">Member</h3><span class="sub" id="mcSub"></span></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px">
|
||||
<button type="button" class="btn small sec" id="mcBack">← Back to list</button>
|
||||
<button type="button" class="btn small sec" data-mcact="username">Set username</button>
|
||||
<button type="button" class="btn small sec" data-mcact="sponsor">Set sponsor</button>
|
||||
<button type="button" class="btn small sec" data-mcact="wallet">Swap main wallet</button>
|
||||
<button type="button" class="btn small sec" data-mcact="credits">Grant credits</button>
|
||||
<a class="btn small sec" id="mcWall" href="#" target="_blank" rel="noopener">Public wall</a>
|
||||
<button type="button" class="btn small sec" data-mcact="delete" style="margin-left:auto">Delete account</button>
|
||||
</div>
|
||||
<div id="mcBody"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Holding tank</h3><span class="sub" id="tankAdmSub">free members with no sponsor, and who adopted whom</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="tankWait"></table></div>
|
||||
<p class="small muted" style="margin:12px 0 6px">Adoptions (newest first)</p>
|
||||
<div class="tablewrap"><table class="adm-table" id="tankAdopt"></table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Members</h3><span class="sub" id="memSub">newest first</span></div>
|
||||
<p style="margin:0 0 10px"><div class="card" id="fraudBox" style="margin-bottom:12px"><p class="small muted">Loading duplicate signals…</p></div>
|
||||
<input id="memFilter" placeholder="Filter by email, username, member # or sponsor" style="width:100%"></p>
|
||||
<p class="small muted">Sponsor = the token the account joined under (username, share code, or member #). Editing it re-points free referrals and future purchases. On-chain sponsorship is permanent once activated.</p>
|
||||
<div class="tablewrap"><table class="adm-table" id="memTable"></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-reports" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Counters audit</h3><span class="sub" id="audSub">views vs delivery logs, charges vs shows</span></div>
|
||||
<p class="small muted" style="margin:0 0 8px">Every format's recorded views are reconciled against the log that proves delivery, login days charged against days shown, and featured bookings checked for views. Runs daily and alerts you on Telegram; run it any time here.</p>
|
||||
<div style="margin:0 0 8px"><button type="button" class="btn small sec" id="audRun">Run now</button></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="audTable"></table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Ad reports</h3><span class="sub">members flagging ads</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="repTable"></table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Pending burns</h3><span class="sub">on-chain credit burns waiting to be executed</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="burnTable"></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-traffic" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Where visitors come from</h3><span class="sub" id="trfSub"></span></div>
|
||||
<div class="chips" id="trfRange" style="margin:0 0 10px"><button type="button" class="chip-t" data-days="7">7 days</button><button type="button" class="chip-t on" data-days="30">30 days</button><button type="button" class="chip-t" data-days="90">90 days</button><button type="button" class="chip-t" data-days="365">Year</button></div>
|
||||
<p class="muted small" style="margin:0 0 10px">Page views are public-page loads by referring domain (crawlers skipped; our own pages and no referrer count as direct). Join-page views are invite-link opens. Signups, registered and $20+ buyers are accounts whose first-touch source was that domain; legacy arrivals show as legacy:brand:domain.</p>
|
||||
<p style="margin:0 0 6px;display:flex;gap:8px;align-items:center"><input class="tfilter small" data-for="trfSources" placeholder="Filter sources" style="max-width:260px"><span class="tfilter-count muted small"></span></p><div class="tablewrap"><table class="adm-table" id="trfSources"></table></div>
|
||||
</div>
|
||||
<div class="grid c2">
|
||||
<div class="card"><div class="card-head"><h3>Landing pages</h3><span class="sub">page views by page</span></div><p style="margin:0 0 6px;display:flex;gap:8px;align-items:center"><input class="tfilter small" data-for="trfPaths" placeholder="Filter pages" style="max-width:260px"><span class="tfilter-count muted small"></span></p><div class="tablewrap"><table class="adm-table" id="trfPaths"></table></div></div>
|
||||
<div class="card"><div class="card-head"><h3>Angles</h3><span class="sub">join-page hook copy</span></div><p style="margin:0 0 6px;display:flex;gap:8px;align-items:center"><input class="tfilter small" data-for="trfAngles" placeholder="Filter angles" style="max-width:260px"><span class="tfilter-count muted small"></span></p><div class="tablewrap"><table class="adm-table" id="trfAngles"></table></div></div>
|
||||
</div>
|
||||
<div class="card"><div class="card-head"><h3>By day</h3><span class="sub">page views, signups</span></div><p style="margin:0 0 6px;display:flex;gap:8px;align-items:center"><input class="tfilter small" data-for="trfDaily" placeholder="Filter days" style="max-width:260px"><span class="tfilter-count muted small"></span></p><div class="tablewrap"><table class="adm-table" id="trfDaily"></table></div></div>
|
||||
<div class="card" id="promoAdmin">
|
||||
<div class="card-head"><h3>Partner promo codes</h3><span class="sub">free ad credits for members who redeem a partner's code</span></div>
|
||||
<p class="muted small" style="margin:0 0 10px">Give a site owner a code. Their members redeem it on a join link (<code>instantadpay.com/join/martbost?promo=CODE</code>) or in the "Have a promo code?" box on the Overview. One use per account; uses and the last redemptions are listed below.</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;align-items:end">
|
||||
<label class="small">Code<input id="pcCode" type="text" placeholder="TRAFFICWAVE" style="text-transform:uppercase"></label>
|
||||
<label class="small">Credits<input id="pcCredits" type="number" min="1" placeholder="250"></label>
|
||||
<label class="small">Partner<input id="pcPartner" type="text" placeholder="site or owner"></label>
|
||||
<label class="small">Max uses (0 = unlimited)<input id="pcMax" type="number" min="0" value="0"></label>
|
||||
<label class="small">Expires (optional)<input id="pcExpires" type="date"></label>
|
||||
<button type="button" class="btn small" id="pcSave">Save code</button>
|
||||
</div>
|
||||
<p class="small" id="pcMsg" hidden style="margin:8px 0 0"></p>
|
||||
<div class="tablewrap" style="margin-top:12px"><table class="adm-table" id="pcTable"></table></div>
|
||||
<div class="tablewrap" style="margin-top:12px"><table class="adm-table" id="pcRecent"></table></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane" id="pane-blog" hidden>
|
||||
<div class="card" id="blogList">
|
||||
<div class="card-head"><h3>Articles</h3><span class="sub" id="blSub"></span></div>
|
||||
<p class="muted small" style="margin:0 0 10px">Coaching and teaching stories, published at <a href="/blog" target="_blank" rel="noopener">instantadpay.com/blog</a>. Every published post gets its own page with a title tag, description, canonical link, social preview card, structured data, and a spot in the sitemap and RSS feed. Drafts are visible only to you (open one from its row to preview the real page).</p>
|
||||
<p class="small muted" id="blSyndNote" hidden style="margin:0 0 10px"></p>
|
||||
<p><button type="button" class="btn small" id="blNew">New article</button></p>
|
||||
<div class="tablewrap"><table class="adm-table" id="blTable"></table></div>
|
||||
</div>
|
||||
<div class="card" id="blogEditor" hidden>
|
||||
<div class="card-head"><h3 id="blEdTitle">New article</h3><span class="sub" id="blEdSub"></span></div>
|
||||
<div style="display:grid;gap:10px">
|
||||
<label class="small">Title (the headline and the browser title; under 60 characters shows whole in search results) <span id="blTitleCount" class="muted"></span><input id="blTitle" type="text" maxlength="140" placeholder="The thimble and the bucket"></label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||
<label class="small">URL slug (letters, numbers, dashes)<input id="blSlug" type="text" maxlength="80" placeholder="auto from the title"></label>
|
||||
<label class="small">Tags (comma separated; the first one shows as the category)<input id="blTags" type="text" maxlength="200" placeholder="mindset, team building"></label>
|
||||
</div>
|
||||
<label class="small">Excerpt (the meta description and the card text; 120 to 160 characters is ideal) <span id="blExcCount" class="muted"></span><textarea id="blExcerpt" maxlength="300" rows="2" style="width:100%"></textarea></label>
|
||||
<div style="display:grid;grid-template-columns:1fr auto;gap:8px;align-items:end">
|
||||
<label class="small">Cover image (1200x630 works best; it becomes the social preview card)<input id="blCover" type="text" placeholder="/uploads/... or https://..."></label>
|
||||
<span><button type="button" class="btn small sec" id="blCoverBtn">Upload</button><input type="file" id="blCoverFile" accept="image/png,image/jpeg,image/webp" hidden></span>
|
||||
</div>
|
||||
<p class="small muted" id="blCoverInfo" style="margin:-4px 0 0"></p>
|
||||
<div>
|
||||
<div class="ed-bar" aria-label="Formatting">
|
||||
<button type="button" data-bl="bold" title="Bold"><b>B</b></button>
|
||||
<button type="button" data-bl="italic" title="Italic"><i>I</i></button>
|
||||
<button type="button" data-blblock="h2" title="Section heading">H2</button>
|
||||
<button type="button" data-blblock="h3" title="Sub heading">H3</button>
|
||||
<button type="button" data-blblock="p" title="Paragraph">P</button>
|
||||
<button type="button" data-blblock="blockquote" title="Quote">Quote</button>
|
||||
<button type="button" data-bl="insertUnorderedList" title="Bullet list">• List</button>
|
||||
<button type="button" data-bl="insertOrderedList" title="Numbered list">1. List</button>
|
||||
<button type="button" id="blLinkBtn" title="Insert link">🔗 Link</button>
|
||||
<button type="button" id="blImgBtn" title="Insert image">🖼 Image</button><input type="file" id="blImgFile" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
|
||||
<button type="button" id="blHtmlBtn" title="Edit the HTML directly"></></button>
|
||||
</div>
|
||||
<div id="blBody" class="ed-body" contenteditable="true" data-ph="Write the article. Headings break it up; short paragraphs read better on phones." style="min-height:360px;line-height:1.6"></div>
|
||||
<textarea id="blHtml" class="ed-body" hidden style="min-height:360px;font-family:var(--mono);font-size:13px"></textarea>
|
||||
<p class="small muted" style="margin:6px 0 0"><span id="blWords">0 words</span> · 600 or more gives a page something to rank on; one idea per section.</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<button type="button" class="btn small" id="blSaveDraft">Save draft</button>
|
||||
<button type="button" class="btn small" id="blPublish">Publish</button>
|
||||
<button type="button" class="btn small sec" id="blUnpublish" hidden>Back to draft</button>
|
||||
<a class="btn small sec" id="blPreview" href="#" target="_blank" rel="noopener" hidden>Open page</a>
|
||||
<button type="button" class="btn small sec" id="blClose">Close</button>
|
||||
<button type="button" class="btn small sec" id="blDelete" hidden style="margin-left:auto">Delete</button>
|
||||
</div>
|
||||
<p class="small" id="blMsg" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane" id="pane-releases" hidden>
|
||||
<div class="grid c2">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Release note</h3><span class="sub">what shipped; shows on /whats-new and every Overview</span></div>
|
||||
<div style="display:grid;gap:8px">
|
||||
<input type="hidden" id="rnId">
|
||||
<label class="small">Title<input id="rnTitle" type="text" maxlength="120" placeholder="Achievement badges post to Telegram"></label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<label class="small">Date<input id="rnDate" type="date"></label>
|
||||
<label class="small">Tags (new, improved, fixed)<input id="rnTags" type="text" placeholder="new, improved"></label>
|
||||
</div>
|
||||
<label class="small">Details (plain text; start a line with "- " for a bullet)<textarea id="rnBody" rows="6" style="width:100%"></textarea></label>
|
||||
<div style="display:flex;gap:8px"><button type="button" class="btn small" id="rnSave">Save note</button><button type="button" class="btn small sec" id="rnClear">Clear</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Roadmap item</h3><span class="sub">what is coming; "done" hides it from the public list</span></div>
|
||||
<div style="display:grid;gap:8px">
|
||||
<input type="hidden" id="rmId">
|
||||
<label class="small">Title<input id="rmTitle" type="text" maxlength="120" placeholder="Second daily ad set"></label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px">
|
||||
<label class="small">Status<select id="rmStatus"><option value="planned">planned</option><option value="building">building</option><option value="done">done</option></select></label>
|
||||
<label class="small">ETA (free text)<input id="rmEta" type="text" placeholder="this week"></label>
|
||||
<label class="small">Order<input id="rmOrder" type="number" min="1" placeholder="1"></label>
|
||||
</div>
|
||||
<label class="small">One-line note<input id="rmNote" type="text" maxlength="600" placeholder="why it matters"></label>
|
||||
<div style="display:flex;gap:8px"><button type="button" class="btn small" id="rmSave">Save item</button><button type="button" class="btn small sec" id="rmClear">Clear</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid c2">
|
||||
<div class="card"><div class="card-head"><h3>Notes</h3><span class="sub" id="rnSub"></span></div><div class="tablewrap"><table class="adm-table" id="rnTable"></table></div></div>
|
||||
<div class="card"><div class="card-head"><h3>Roadmap</h3><span class="sub" id="rmSub"></span></div><div class="tablewrap"><table class="adm-table" id="rmTable"></table></div></div>
|
||||
</div>
|
||||
<div class="card" id="updCard">
|
||||
<div class="card-head"><h3>Email an update to members</h3><span class="sub" id="updSub"></span></div>
|
||||
<p class="small muted" style="margin:0 0 10px">Pick the notes to include, add a line or two in your own words, preview, send yourself a test, then send. Plain text from no-reply@instantadpay.com, one member at a time. The default audience is members who ticked the newsletter box at sign-up (the Sendy list is the record); unsubscribes are skipped. Nothing goes out until you press Send.</p>
|
||||
<div class="grid c2">
|
||||
<div style="display:grid;gap:8px">
|
||||
<label class="small">Subject<input id="updSubject" type="text" maxlength="150" placeholder="What is new on InstantAdPay this week"></label>
|
||||
<label class="small">Intro (optional, plain text)<textarea id="updIntro" rows="4" style="width:100%" placeholder="Quick one. Three things shipped since my last note, and the third one is the one to try today."></textarea></label>
|
||||
<label class="small">Closing (after the notes, e.g. your sign-off)<textarea id="updClosing" rows="2" style="width:100%" placeholder="See you Monday. Marty"></textarea></label>
|
||||
<label class="small">Audience<select id="updAudience"></select></label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="button" class="btn small sec" id="updSaveDraft">Save draft</button><button type="button" class="btn small sec" id="updPreview">Preview</button><button type="button" class="btn small sec" id="updTest">Send test to me</button><button type="button" class="btn small" id="updSend">Send</button></div>
|
||||
<pre id="updPre" class="small" hidden style="white-space:pre-wrap;background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:10px;padding:10px;max-height:320px;overflow:auto"></pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="small muted" style="margin:0 0 6px">Release notes to include (newest first; notes since your last send are pre-ticked)</div>
|
||||
<div id="updNotes" style="display:grid;gap:4px;max-height:260px;overflow:auto"></div>
|
||||
<div class="small muted" style="margin:12px 0 6px">Recent sends</div>
|
||||
<div class="tablewrap"><table class="adm-table" id="updLog"></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small muted">Public page: <a href="/whats-new" target="_blank" rel="noopener">instantadpay.com/whats-new</a></p>
|
||||
</div>
|
||||
<div class="pane" id="pane-pnl" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Profit and loss</h3><span class="sub">read from the chain index; periods are by block (about 43,200 Polygon blocks a day)</span></div>
|
||||
<div class="chips" id="pnlPeriods"><button type="button" class="chip-t" data-days="7">7 days</button><button type="button" class="chip-t on" data-days="30">30 days</button><button type="button" class="chip-t" data-days="90">90 days</button><button type="button" class="chip-t" data-days="0">All time</button></div>
|
||||
<div class="grid" id="pnlTiles" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));margin-top:12px"></div>
|
||||
</div>
|
||||
<div class="grid c2">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Where the money went</h3><span class="sub">per period</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="pnlSplit"></table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>PolHunter payouts</h3><span class="sub">all time, its own faucet</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="pnlHunt"></table></div>
|
||||
<p class="small muted" style="margin:10px 0 0">Marketing spend, paid in POL from the PolHunter wallet. None of it passes through the ad contract, so it is not in the lines above.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Wallets</h3><span class="sub">live balances</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="pnlWallets"></table></div>
|
||||
<p class="small muted" style="margin:10px 0 0">Fixed monthly cost (USD) for the net line: <input type="number" id="pnlFixed" min="0" step="1" style="width:110px"> <button class="btn small sec" type="button" id="pnlFixedSave">Save</button></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Wallet ledger</h3><span class="sub">money in and out that the chain cannot explain</span></div>
|
||||
<p class="small muted" style="margin:0 0 10px">The lines above come from contract events and live balances. Anything else that moves through a receiver goes here: outside income, money drawn out and spent, and your own funds parked for safekeeping. <b>Revenue</b> counts toward profit. <b>Draw</b> is house profit spent on something. <b>Transfer</b> moves the balance only and is never counted as earnings.</p>
|
||||
<div id="pnlRecon" class="small" style="margin-bottom:10px"></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="pnlLedger"></table></div>
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;margin-top:12px">
|
||||
<select id="ldDir"><option value="in">Money in</option><option value="out">Money out</option></select>
|
||||
<select id="ldKind"><option value="revenue">Revenue</option><option value="draw">Draw</option><option value="transfer">Transfer</option></select>
|
||||
<select id="ldWallet"><option value="feeA">Receiver A</option><option value="feeB">Receiver B</option><option value="engine">Engine</option><option value="hunt">PolHunter</option></select>
|
||||
<input type="number" id="ldAmount" min="0" step="0.0001" placeholder="Amount in POL">
|
||||
<input type="number" id="ldUsd" min="0" step="0.01" placeholder="USD (optional)">
|
||||
<input type="date" id="ldDate">
|
||||
</div>
|
||||
<div class="grid" style="grid-template-columns:1fr auto;gap:8px;margin-top:8px">
|
||||
<input type="text" id="ldNote" maxlength="200" placeholder="What was this for? e.g. RM Circle #139 Culmen to Apex">
|
||||
<button class="btn small" type="button" id="ldAdd">Add line</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Automatic credit burner</h3><span class="sub">settles pending campaign spend on-chain from the engine wallet</span></div>
|
||||
<p class="small" id="burnerLine">…</p>
|
||||
<p><button class="btn small sec" type="button" id="burnerRun">Run now</button></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane" id="pane-settings" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Follow-up emails</h3><span class="sub" id="dripSub">sent to every new free member, hours after sign-up</span></div>
|
||||
<p class="small muted">One card per email. Placeholders fill in per reader: click a chip to drop it where your cursor is.
|
||||
<span class="ph-chips" id="phChips">
|
||||
<button type="button" class="chip-t" data-ph="{{link}}" title="the reader's own invite link">{{link}}</button>
|
||||
<button type="button" class="chip-t" data-ph="{{sponsor}}" title="their sponsor's name">{{sponsor}}</button>
|
||||
<button type="button" class="chip-t" data-ph="{{site}}" title="https://instantadpay.com">{{site}}</button>
|
||||
<button type="button" class="chip-t" data-ph="{{paid:words if they bought|words if they have not}}" title="two versions: bought / not yet">{{paid:…|…}}</button>
|
||||
<button type="button" class="chip-t" data-ph="{{footer}}" title="unsubscribe line + disclaimer (keep it at the end)">{{footer}}</button>
|
||||
</span></p>
|
||||
<div id="dripSteps" class="drip-steps"></div>
|
||||
<p id="dripErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<p style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:14px 0 0">
|
||||
<button class="btn small" id="dripSave" type="button">Save sequence</button>
|
||||
<button class="btn small sec" id="dripAdd" type="button">+ Add an email</button>
|
||||
<button class="btn small sec" id="dripReset" type="button">Reset to defaults</button>
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Rates</h3><span class="sub">credit prices, rewards and limits</span></div>
|
||||
<div id="ratesForm" class="rates-form"></div>
|
||||
<p id="ratesErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<p style="margin:14px 0 0"><button class="btn small" id="ratesSave" type="button">Save rates</button></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Site settings</h3><span class="sub">public: everything here is served to the browser, never put a secret in it</span></div>
|
||||
<div id="siteForm" class="kv-form"></div>
|
||||
<p style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:12px 0 0">
|
||||
<input id="siteNewKey" placeholder="new setting name" style="max-width:220px">
|
||||
<button class="btn small sec" id="siteAddKey" type="button">+ Add setting</button>
|
||||
</p>
|
||||
<p id="siteErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<p style="margin:14px 0 0"><button class="btn small" id="siteSave" type="button">Save site settings</button></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/common.js?v=20260923b"></script><script src="/assets/welcome.js?v=20260923b"></script>
|
||||
<script src="/assets/admin.js?v=20260923b"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// 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) {} })();
|
||||
// Viral links (2026-09-21): a signed-in member sees this article's address with their own ?ref= on the
|
||||
// end, and the share buttons carry it too. Whoever opens it is tagged to them like an invite link.
|
||||
(async function () {
|
||||
try {
|
||||
const share = document.querySelector('p.share'); if (!share) return;
|
||||
const me = await (await fetch('/api/me')).json();
|
||||
const tok = me && me.signedIn && (me.username || me.refCode); if (!tok) return;
|
||||
const link = 'https://instantadpay.com' + location.pathname + '?ref=' + encodeURIComponent(tok);
|
||||
share.querySelectorAll('a').forEach(a => { a.href = a.href.replace(encodeURIComponent('https://instantadpay.com' + location.pathname), encodeURIComponent(link)); });
|
||||
const bar = document.createElement('div');
|
||||
bar.id = 'viralBar';
|
||||
bar.style.cssText = 'margin-top:18px;padding:12px 14px;border:1px solid var(--line);border-radius:12px;background:var(--card, rgba(255,255,255,.03));display:flex;gap:10px;align-items:center;flex-wrap:wrap';
|
||||
bar.innerHTML = '<div style="flex:1;min-width:220px"><div class="eyebrow" style="margin:0 0 4px">Your link for this article</div><div class="mono small" style="overflow-wrap:anywhere">' + link.replace(/[&<>]/g, '') + '</div><div class="muted small" style="margin-top:4px">Share this and anyone who joins from it is yours, same as your invite link.</div></div><button class="btn small" type="button">Copy link</button>';
|
||||
bar.querySelector('button').onclick = async () => { try { await navigator.clipboard.writeText(link); IAP.status('Link copied.', 'ok'); } catch (e) { IAP.status('Copy failed.', 'bad'); } };
|
||||
share.parentNode.insertBefore(bar, share);
|
||||
} catch (e) {}
|
||||
})();
|
||||
@@ -29,9 +29,10 @@ window.IAP = (function () {
|
||||
nav.innerHTML = '<div class="wrap">'
|
||||
+ '<span class="logo-wrap"><a class="logo" href="/"><img src="/logo.png" alt="InstantAdPay" style="height:30px;display:block"></a><span class="byline">Brought to you by the <b>Crypto Team Build Network</b></span></span>'
|
||||
+ '<span class="links">'
|
||||
+ '<a href="/" data-p="home">How it works</a>'
|
||||
+ '<a href="/#ladder" data-p="pricing">Ad packages</a>'
|
||||
+ '<a href="/#how" data-p="home">How it works</a>'
|
||||
+ '<a href="/#packages" data-p="pricing">Ad packages</a>'
|
||||
+ '<a href="/ledger" data-p="ledger">Live ledger</a>'
|
||||
+ '<a href="/leaderboard" data-p="leaderboard">Leaderboard</a>'
|
||||
+ '<a href="/contract" data-p="contract">The contract</a>'
|
||||
+ '<a href="/my" data-p="my">Members</a>'
|
||||
+ '</span><span id="navWallet" class="muted">…</span></div>';
|
||||
@@ -53,7 +54,7 @@ window.IAP = (function () {
|
||||
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 = '<div>© ' + new Date().getFullYear() + ' InstantAdPay</div>'
|
||||
+ '<div style="margin-top:8px;display:flex;gap:16px;justify-content:center;flex-wrap:wrap">'
|
||||
+ '<a href="/">How it works</a><a href="/ledger">Live ledger</a><a href="/contract">The contract</a>'
|
||||
+ '<a href="/">How it works</a><a href="/ledger">Live ledger</a><a href="/contract">The contract</a><a href="/blog">Blog</a><a href="/leaderboard">Leaderboard</a><a href="/whats-new">What\'s new</a>'
|
||||
+ '<a href="/terms">Terms</a><a href="/privacy">Privacy</a><a href="/disclaimer">Disclaimer</a></div>';
|
||||
document.body.appendChild(f);
|
||||
}
|
||||
@@ -109,12 +110,18 @@ window.IAP = (function () {
|
||||
}
|
||||
// Render one served ad into #<elId>. Silent if no inventory.
|
||||
// report an ad (auto-approved ads need a member-facing flag → admin notified)
|
||||
function reportAd(campaignId) {
|
||||
// 2026-09-16: was a native prompt() with 'broken' pre-filled, so a stray tap on OK filed a
|
||||
// "broken" report with no note (campaign #27). Now the site dialog, no default, reason validated.
|
||||
const REPORT_REASONS = ['broken', 'inappropriate', 'spam', 'scam', 'other'];
|
||||
async 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 }) })
|
||||
const raw = await ask({ title: 'Report this ad', text: 'What is wrong with it? Type one: broken, inappropriate, spam, scam, or other.', placeholder: 'e.g. broken', ok: 'Next' });
|
||||
if (raw === null) return;
|
||||
const reason = String(raw || '').trim().toLowerCase();
|
||||
if (!REPORT_REASONS.includes(reason)) { status('Pick one of: broken, inappropriate, spam, scam, or other.', 'bad'); return; }
|
||||
const note = await ask({ title: 'Report: ' + reason, text: 'Tell the admin what you saw, so it can be fixed. Optional, but it helps.', type: 'textarea', ok: 'Send report' });
|
||||
if (note === null) return;
|
||||
fetch('/api/report-ad', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ campaignId, reason, note: String(note || '').slice(0, 500) }) })
|
||||
.then(() => status('Thanks — this ad was reported to the admin for review.', 'ok'))
|
||||
.catch(() => status('Could not send the report. Try again.', 'bad'));
|
||||
}
|
||||
@@ -143,5 +150,117 @@ window.IAP = (function () {
|
||||
el.hidden = false;
|
||||
} catch (e) {}
|
||||
}
|
||||
return { getConfig, fmtPol, fmtUsd, status, renderNav, refreshNavWallet, describeEvent, feedRow, adSlot, reportAd, $ };
|
||||
// ── 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 = '<div class="small" style="margin:0 0 8px">' + (note ? esc(note) + ' ' : '') + 'Tap the <b>' + esc(ch.prompt) + '</b>.</div>'
|
||||
+ '<div class="icon-check">' + ch.options.map(o => '<button type="button" class="ic-btn">' + esc(o) + '</button>').join('') + '</div>';
|
||||
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 || '') : '';
|
||||
// The server refuses a code request that arrives within a couple of seconds of the page loading,
|
||||
// because a script fills a form instantly and a person does not. A returning member whose email
|
||||
// is already autofilled has nothing to type, taps straight away, and gets told off for being
|
||||
// quick (Marty, 2026-09-23). So the page waits out the remainder itself: the person sees the
|
||||
// normal sending state, and anything posting at the endpoint without a page behind it still
|
||||
// fails the same check.
|
||||
const MIN_FORM_MS = 2400; // the server wants 2000; leave headroom for clock skew in transit
|
||||
function formSettled() {
|
||||
const left = MIN_FORM_MS - (Date.now() - FORM_TS);
|
||||
return left > 0 ? new Promise(r => setTimeout(r, left)) : Promise.resolve();
|
||||
}
|
||||
async function requestCode(email, opts) {
|
||||
const o = opts || {};
|
||||
let pick = null;
|
||||
await formSettled();
|
||||
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: <b>' + bc + '</b> 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: <b>' + bc + '</b> 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: <b>' + refs + '</b>.' }
|
||||
];
|
||||
}
|
||||
// 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'
|
||||
? '<textarea id="dlgInput" rows="5" style="width:100%;margin-top:12px">' + esc(o.value) + '</textarea>'
|
||||
: '<input id="dlgInput" type="' + (o.type === 'number' ? 'number' : 'text') + '" ' + (o.type === 'number' ? 'inputmode="decimal" min="0" step="any" ' : '') + 'value="' + esc(o.value) + '" placeholder="' + esc(o.placeholder) + '" style="width:100%;margin-top:12px" autocomplete="off">';
|
||||
back.innerHTML = '<div class="modal-card" role="dialog" aria-modal="true">' + (o.title ? '<h3 style="margin:0 0 8px">' + esc(o.title) + '</h3>' : '')
|
||||
+ (o.text ? '<p class="muted small" style="margin:0;white-space:pre-line">' + esc(o.text) + '</p>' : '') + field
|
||||
+ '<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:16px;flex-wrap:wrap"><button type="button" class="btn sec small" id="dlgCancel">' + esc(o.cancel || 'Cancel') + '</button><button type="button" class="btn small" id="dlgOk">' + esc(o.ok || 'OK') + '</button></div></div>';
|
||||
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, $ };
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/* /friday status strip (Marty, 2026-09-25). External file on purpose: the site's CSP blocks
|
||||
inline scripts, which is how the first deploy shipped a strip stuck on "Checking". */
|
||||
(function () {
|
||||
var el = document.getElementById('frState');
|
||||
if (!el) return;
|
||||
function n(x) { return Number(x || 0).toLocaleString('en-US'); }
|
||||
// the minute (Central) when the next Friday starts, found by scanning rather than assumed,
|
||||
// so a daylight-saving change cannot put the countdown an hour out
|
||||
function startsAt(dayKey) {
|
||||
var now = Date.now();
|
||||
for (var m = 0; m < 8 * 1440; m++) {
|
||||
var t = now + m * 60000;
|
||||
var d = new Date(t).toLocaleDateString('en-CA', { timeZone: 'America/Chicago' });
|
||||
if (d === dayKey) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function render(f) {
|
||||
if (!f || !f.on) { el.innerHTML = '<span class="fr-tag">Paused</span><span>Five Dollar Friday is not running right now. Join free and you will hear when it is back.</span>'; return; }
|
||||
if (f.live) {
|
||||
var t = f.today || {};
|
||||
el.className = 'fr-state live';
|
||||
el.innerHTML = '<span class="fr-tag">Live now</span><span><b>It is Five Dollar Friday.</b> Bonus runs until midnight Central.</span>'
|
||||
+ '<span><span class="fr-num">' + n(t.packs) + '</span> pack' + (t.packs === 1 ? '' : 's') + ' so far today</span>'
|
||||
+ '<span><span class="fr-num">' + n(t.bonusCredits) + '</span> bonus credits issued</span>';
|
||||
return;
|
||||
}
|
||||
var at = f.nextFriday ? startsAt(f.nextFriday) : null;
|
||||
var left = at ? Math.max(0, at - Date.now()) : 0;
|
||||
var hrs = Math.floor(left / 3600000), mins = Math.floor((left % 3600000) / 60000), days = Math.floor(hrs / 24);
|
||||
var when = at ? (days >= 1 ? days + ' day' + (days === 1 ? '' : 's') + ' ' + (hrs % 24) + 'h' : hrs + 'h ' + mins + 'm') : '';
|
||||
el.className = 'fr-state';
|
||||
el.innerHTML = '<span class="fr-tag">Next</span><span><b>Next Five Dollar Friday: ' + (f.nextLabel || f.nextFriday || 'soon') + '.</b> Starts at midnight Central' + (when ? ', in ' + when : '') + '.</span>'
|
||||
+ '<span>Join now so your account is ready before it starts.</span>';
|
||||
}
|
||||
fetch('/api/friday').then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }).then(render);
|
||||
})();
|
||||
@@ -53,6 +53,15 @@
|
||||
IAP.status('Purchase failed: ' + (e.message || e), 'bad');
|
||||
} finally { btn.disabled = false; }
|
||||
}
|
||||
// Five Dollar Friday strip under the stats: the live wave on Fridays, the next date otherwise
|
||||
async function loadFriday() {
|
||||
try { const f = await (await fetch('/api/friday')).json(); const el = document.getElementById('fdfStrip'); if (!f.on || !el) return; el.hidden = false;
|
||||
el.innerHTML = f.live && f.today
|
||||
? '<b>\u{1F4B5} It\u2019s Five Dollar Friday.</b> ' + f.today.packs + ' pack' + (f.today.packs === 1 ? '' : 's') + ' bought so far today, ' + f.today.polPaid.toFixed(2) + ' POL paid to members in the same transactions. Any pack from $5 earns +' + f.pct + '% credits until midnight Central. <a href="/my">Get yours</a>'
|
||||
: '<b>\u{1F4B5} Five Dollar Friday:</b> every Friday, any ad package from $5 up earns +' + f.pct + '% bonus credits, and the whole network buys on the same day. Next one: ' + (f.nextLabel || 'soon') + '. <a href="/my">Join free and line yours up</a>';
|
||||
} catch (e) {}
|
||||
}
|
||||
loadFriday();
|
||||
async function loadStats() {
|
||||
try {
|
||||
const s = await (await fetch('/api/stats')).json();
|
||||
@@ -69,11 +78,19 @@
|
||||
const inner = IAP.$('tickerInner');
|
||||
inner.innerHTML = events.map(ev => '<span>' + IAP.describeEvent(ev, c) + '</span>').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: 'Level 1 is open to every member: your direct referrals each pay you 50 percent of every package they ever buy, straight to your wallet.' },
|
||||
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.' }
|
||||
};
|
||||
|
||||
@@ -4,16 +4,54 @@
|
||||
// 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 <em>pays you back.</em>',
|
||||
lead: brand + ' is closed. The people who bought ads there are exactly who InstantAdPay 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. <em>Real payouts on-chain.</em>',
|
||||
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 = {
|
||||
instant: { eyebrow: 'Same-transaction payouts', h: 'Paid before the page <em>reloads.</em>', lead: 'What if your commission landed before the thank-you page finished loading? On InstantAdPay that is not a metaphor. A smart contract on Polygon splits every ad package the moment it sells.' },
|
||||
adspend: { eyebrow: 'For marketers who buy traffic', h: 'You were buying <em>ads anyway.</em>', 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.' },
|
||||
free: { eyebrow: 'Costs nothing to try', h: 'Watch first. <em>Spend never.</em>', lead: 'Join free, view a few ads, earn credits, and run your first campaign for zero dollars. Upgrade only if you want more reach.' },
|
||||
ledger: { eyebrow: 'No back office', h: 'No back office. <em>No payday.</em>', 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.' },
|
||||
two: { eyebrow: 'The referral side, exactly as coded', h: 'Two buyers open <em>level two.</em>', 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.' }
|
||||
'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 <em>reloads.</em>', lead: 'What if your commission landed before the thank-you page finished loading? On InstantAdPay 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 <em>ads anyway.</em>', 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. <em>Spend never.</em>', 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. <em>No payday.</em>', 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 <em>level two.</em>', 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.' }
|
||||
};
|
||||
const v = new URLSearchParams(location.search).get('v');
|
||||
// ?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) { $('jnEyebrow').textContent = '· ' + a.eyebrow; $('jnHead').innerHTML = a.h; $('jnLead').textContent = a.lead; }
|
||||
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 => '<li>' + t.replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])) + '</li>').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();
|
||||
@@ -24,16 +62,19 @@
|
||||
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 api('/api/auth/email/start', { email: $('jnEmail').value });
|
||||
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 api('/api/auth/email/start', { email: $('jnEmail').value });
|
||||
const r = await IAP.requestCode($('jnEmail').value, codeOpts());
|
||||
if (r.devCode) $('jnCode').value = r.devCode;
|
||||
IAP.status('Fresh code sent.', 'ok');
|
||||
}));
|
||||
@@ -45,11 +86,15 @@
|
||||
$('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
|
||||
fetch('/api/sponsor').then(r => r.json()).then(sp => {
|
||||
// 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) {
|
||||
$('jnSponName').textContent = 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(() => {});
|
||||
|
||||
@@ -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.
|
||||
|
||||
InstantAdPay 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) => '<div class="swipe"><div class="cap"><span>' + s.when + '</span><button type="button" class="btn sec" data-post="' + i + '">Copy post</button></div><pre>' + s.text.replace('{link}', LINK).replace(/&/g, '&').replace(/</g, '<') + '</pre></div>').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) => '<div class="swipe"><div class="cap"><span>Email ' + (i + 1) + ' · ' + s.when + '</span><button type="button" class="btn sec" data-swipe="' + i + '">Copy email</button></div><div class="subj">Subject: ' + s.subject + '</div><pre>' + s.body.replace('{link}', LINK).replace(/&/g, '&').replace(/</g, '<') + '</pre></div>').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) => '<li class="' + (i.done ? 'done' : '') + '"><div class="box">' + (i.done ? '✓' : (n + 1)) + '</div>'
|
||||
+ '<div><h3>' + i.title + '</h3><p>' + i.how + '</p><div class="why">' + i.why + '</div>' + (i.note ? '<p class="small" style="margin-top:4px">' + i.note + '</p>' : '') + '</div>'
|
||||
+ '<div class="act">' + (i.manual ? '<button class="btn small ' + (i.done ? 'sec' : '') + '" type="button" data-manual="' + i.key + '">' + (i.done ? 'Undo' : 'Mark done') + '</button>' : '<a class="btn small sec" href="' + i.href + '">' + i.cta + '</a>') + '</div></li>').join('');
|
||||
$('chk').querySelectorAll('[data-manual]').forEach(b => b.addEventListener('click', () => { IAP.launchToggle(b.dataset.manual); render(); }));
|
||||
}
|
||||
render();
|
||||
})();
|
||||
@@ -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://instantadpay.com/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.';
|
||||
})();
|
||||
@@ -8,9 +8,17 @@ window.IAPPromo = (function () {
|
||||
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;
|
||||
// The Five Dollar Friday bridge page with the member's referral: /friday?ref=<username>. Any
|
||||
// public page plus ?ref sets the same sponsor cookie as the invite link (viral links, 2026-09-21).
|
||||
const fridayLink = link => link.replace(/\/join\/([^/?#]+).*$/, '/friday?ref=$1');
|
||||
|
||||
// ── social posts ──
|
||||
const POSTS = [
|
||||
{ net: 'X', label: 'X · Five Dollar Friday', text: 'Five Dollar Friday on InstantAdPay: any ad package from $5 up earns 20% bonus credits, every Friday, Central time. The pack pays the sponsor line in the same transaction, on a public ledger. Free to join: {{FRIDAY}}' },
|
||||
{ net: 'Facebook', label: 'Facebook · Five Dollar Friday', text: 'Every Friday I point people at this. Any ad package from $5 up gets 20% extra credits on InstantAdPay, added the second the purchase settles, no code, nothing to claim. And every pack pays the sponsor line in the same blockchain transaction, so there is no pending anything. It runs midnight to midnight Central. Free to join by email: {{FRIDAY}}' },
|
||||
{ net: 'Telegram', label: 'Telegram or WhatsApp group · Five Dollar Friday', text: 'Quick one for the group: Five Dollar Friday on InstantAdPay. Any ad package from $5 up earns 20% bonus credits today (Central time, until midnight). The page shows whether it is live right now: {{FRIDAY}}' },
|
||||
{ net: 'X', label: 'X · Five Dollar Friday', text: 'Five Dollar Friday on InstantAdPay: every Friday, any ad package from $5 earns +20% bonus credits, and every pack pays its sponsor line in the same on-chain transaction. The whole network buys on the same day. Join free and line yours up: {{LINK}}' },
|
||||
{ net: 'Telegram', label: 'Telegram or WhatsApp · Five Dollar Friday', text: 'Reminder for the group: tomorrow is Five Dollar Friday on InstantAdPay. Any pack from $5 up earns +20% credits, and your pack pays your sponsor line the second it settles. Everyone pushes on the same day, and every line gets paid at once. Free to join: {{LINK}}' },
|
||||
{ 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}}' },
|
||||
@@ -23,6 +31,7 @@ window.IAPPromo = (function () {
|
||||
|
||||
// ── text a friend (SMS-sized) ──
|
||||
const TEXTS = [
|
||||
{ angle: 'friday', title: '"Five Dollar Friday"', text: 'Heads up: Fridays on InstantAdPay any ad package from $5 up gets 20% bonus credits, Central time. Page tells you if it is live right now: {{FRIDAY}}' },
|
||||
{ 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}}' },
|
||||
@@ -32,6 +41,7 @@ window.IAPPromo = (function () {
|
||||
|
||||
// ── email swipes ──
|
||||
const SWIPES = [
|
||||
{ tier: 'Five Dollar Friday', subject: 'Tomorrow is Five Dollar Friday', body: 'Quick heads-up.\n\nTomorrow is Five Dollar Friday on InstantAdPay. Any ad package from $5 up, bought on Friday (Central time), earns +20% bonus credits the moment it settles.\n\nHere is why it matters: every pack pays its sponsor line in the same transaction, on the Polygon blockchain. When the whole network buys on the same day, every line gets paid at once, and you can watch it happen on the public ledger.\n\nIt is free to join. Line up your pack tonight so you are ready when Friday starts:\n{{LINK}}\n\nAdvertising with a referral program. Not an investment, no income guaranteed.' },
|
||||
{ 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\nInstantAdPay 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\nInstantAdPay 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.' },
|
||||
@@ -52,7 +62,7 @@ window.IAPPromo = (function () {
|
||||
];
|
||||
|
||||
function fillLink(text, link) {
|
||||
return text.replace(/\{\{LINK(?::([a-z]+))?\}\}/g, (m, a) => angleLink(link, a));
|
||||
return text.replace(/\{\{FRIDAY\}\}/g, fridayLink(link)).replace(/\{\{LINK(?::([a-z]+))?\}\}/g, (m, a) => angleLink(link, a));
|
||||
}
|
||||
function copyBtn(text, label) {
|
||||
const b = document.createElement('button');
|
||||
@@ -90,6 +100,15 @@ window.IAPPromo = (function () {
|
||||
{ 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 InstantAdPay 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
|
||||
@@ -97,6 +116,29 @@ window.IAPPromo = (function () {
|
||||
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 = '<summary><span><span class="angle-name">' + esc(a.name) + '</span> <span class="angle-hook">' + esc(a.hook) + '</span></span>'
|
||||
+ (hasVideo ? '<span class="angle-tag">video</span>' : '') + '</summary>'
|
||||
+ '<div class="angle-body"><div class="angle-txt">'
|
||||
+ '<div class="muted small">' + esc(a.use) + '</div>'
|
||||
+ '<a class="mono small angle-url" href="' + esc(url) + '" target="_blank" rel="noopener">' + esc(url) + '</a></div>'
|
||||
+ '<div class="angle-btns"><a class="btn small sec" href="' + esc(url) + '" target="_blank" rel="noopener">Open</a><button class="btn small" type="button">Copy</button>'
|
||||
+ (navigator.share ? '<button class="btn small sec" type="button">Share</button>' : '') + '</div></div>'; // 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: 'InstantAdPay', 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) {
|
||||
@@ -163,6 +205,68 @@ window.IAPPromo = (function () {
|
||||
vw.appendChild(card);
|
||||
}
|
||||
}
|
||||
// viral link builder: any page on the site + ?ref=<you> (same cookie as the invite link)
|
||||
const vp = $('viralPage');
|
||||
if (vp && token && vp.dataset.filled !== token) {
|
||||
vp.dataset.filled = token;
|
||||
const origin = 'https://instantadpay.com', enc = encodeURIComponent;
|
||||
const build = path => { if (!path) return ''; let x; try { x = new URL(origin + path); } catch (e) { return ''; } x.searchParams.set('ref', token); return origin + x.pathname + x.search; };
|
||||
const render = () => {
|
||||
const l = build(vp.value);
|
||||
$('viralLink').textContent = l || '…';
|
||||
const sh = $('viralShare'); sh.innerHTML = '';
|
||||
if (!l) return;
|
||||
const title = vp.selectedOptions[0] ? vp.selectedOptions[0].textContent.replace(/^Article: /, '') : 'InstantAdPay';
|
||||
[linkBtn('https://x.com/intent/tweet?url=' + enc(l) + '&text=' + enc(title), 'X'),
|
||||
linkBtn('https://www.facebook.com/sharer/sharer.php?u=' + enc(l), 'Facebook'),
|
||||
linkBtn('https://t.me/share/url?url=' + enc(l) + '&text=' + enc(title), 'Telegram'),
|
||||
linkBtn('https://wa.me/?text=' + enc(title + ' ' + l), 'WhatsApp'),
|
||||
linkBtn('https://www.linkedin.com/sharing/share-offsite/?url=' + enc(l), 'LinkedIn'),
|
||||
linkBtn('mailto:?subject=' + enc(title) + '&body=' + enc(l), 'Email')].forEach(b => sh.appendChild(b));
|
||||
if (navigator.share) { const b = document.createElement('button'); b.className = 'btn small sec'; b.type = 'button'; b.textContent = 'Share'; b.onclick = async () => { try { await navigator.share({ title, url: l }); } catch (e) {} }; sh.appendChild(b); }
|
||||
};
|
||||
fetch('/api/pages').then(r => r.json()).then(r => { vp.innerHTML = (r.pages || []).map(pg => '<option value="' + esc(pg.path) + '">' + esc(pg.title) + '</option>').join(''); render(); }).catch(() => { vp.innerHTML = '<option value="/">Home page</option>'; render(); });
|
||||
vp.onchange = render;
|
||||
$('viralCopy').onclick = async () => { const l = $('viralLink').textContent; if (!/^https:/.test(l)) return; try { await navigator.clipboard.writeText(l); status('Link copied.', 'ok'); } catch (e) { status('Copy failed.', 'bad'); } };
|
||||
}
|
||||
// PolHunter, the feeder program: the member's PolHunter link (their IAP username, so sign-ups land on their line)
|
||||
const phl = $('phLink');
|
||||
if (phl && token && phl.dataset.filled !== token) {
|
||||
phl.dataset.filled = token;
|
||||
const enc = encodeURIComponent, pl = 'https://polhunter.com/?r=' + enc(token);
|
||||
phl.textContent = pl;
|
||||
$('phLinkCopy').onclick = async () => { try { await navigator.clipboard.writeText(pl); status('Link copied.', 'ok'); } catch (e) { status('Copy failed.', 'bad'); } };
|
||||
const sh = $('phShare'); sh.innerHTML = '';
|
||||
const hook = 'PolHunter: find a code on one of our sites and a drip of POL lands in your wallet, on chain. Free to start.';
|
||||
[linkBtn('https://x.com/intent/tweet?url=' + enc(pl) + '&text=' + enc(hook), 'X'), linkBtn('https://www.facebook.com/sharer/sharer.php?u=' + enc(pl), 'Facebook'), linkBtn('https://t.me/share/url?url=' + enc(pl) + '&text=' + enc(hook), 'Telegram'), linkBtn('https://wa.me/?text=' + enc(hook + ' ' + pl), 'WhatsApp'), linkBtn('mailto:?subject=' + enc('A scavenger hunt that pays in POL') + '&body=' + enc(hook + '\n\n' + pl), 'Email')].forEach(b => sh.appendChild(b));
|
||||
const PH_POSTS = [
|
||||
{ name: 'Short post', text: 'New thing I am playing with: PolHunter. You visit a site, find your code on the page, tap it to copy, paste it on your board, and a random drip of POL lands in your wallet. On chain, with a link to prove it. ' + pl },
|
||||
{ name: 'Story post', text: 'I spent two minutes on a training page tonight, found a little glass coin at the bottom, tapped the code, and 0.26 POL showed up in my wallet a minute later. That is PolHunter. Small drips, real chain, no catch beyond actually visiting the site. Free to start, you just need an InstantAdPay account and a wallet. ' + pl },
|
||||
{ name: 'Telegram or WhatsApp', text: '\u{1F3AF} PolHunter is live. Missions across our sites, one code to find per site, random POL drips paid straight to your wallet. The hiding place moves every day. Start here: ' + pl },
|
||||
{ name: 'Email swipe', subject: 'A scavenger hunt that pays in POL', text: 'Hey,\n\nQuick one. I have been testing something called PolHunter.\n\nYou pick a mission, spend a couple of minutes on the site it names, and your personal code appears somewhere on the page. Tap it to copy, paste it on your board, and a random drip of POL goes to your wallet, on chain, with a link to prove it. Three missions a day, a leaderboard with weekly prizes, and the hiding place moves every day.\n\nIt is free to start. You need an InstantAdPay account (also free) and a wallet, and the site walks you through both.\n\nStart here: ' + pl + '\n\nTell me what your first find pays.' },
|
||||
];
|
||||
// the two spots (photoreal / animated): player, download, caption with the member's link, share buttons
|
||||
const SP = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/';
|
||||
const SPOTS = [
|
||||
{ id: 'photoreal', name: 'Photoreal: the hunt in the forest', file: 'polhunter-spot-photoreal.mp4', poster: 'polhunter-spot-photoreal-poster.jpg', best: 'Facebook, YouTube Shorts, an older audience', cap: 'Somewhere on one of our sites, a code is waiting. Find it, tap it, paste it on your board, and a drip of Polygon lands in your wallet. On chain, with a link to prove it. Free to play. Start here: ' + pl + ' #PolHunter #Polygon #crypto #scavengerhunt' },
|
||||
{ id: 'animated', name: 'Animated: the explorer and the glass coin', file: 'polhunter-spot-animated.mp4', poster: 'polhunter-spot-animated-poster.jpg', best: 'TikTok, Instagram Reels, a younger crowd', cap: 'A scavenger hunt that pays in Polygon. Pick a mission, find the glass coin, tap it, and the drip lands in your wallet instantly. Three missions a day, badges, weekly prizes. 100% free. ' + pl + ' #PolHunter #Polygon #crypto #gaming' },
|
||||
];
|
||||
const ps = $('phSpots'); if (ps) { ps.innerHTML = '';
|
||||
for (const s of SPOTS) { const d = document.createElement('div'); d.className = 'promo-block';
|
||||
d.innerHTML = '<div class="pb-head"><span class="lab">' + esc(s.name) + '</span> <span class="muted small">Best on: ' + esc(s.best) + '</span></div><video controls playsinline preload="none" poster="' + SP + s.poster + '" src="' + SP + s.file + '" style="width:100%;max-width:300px;border-radius:12px;display:block;margin:8px auto"></video><div class="pb-text">' + esc(s.cap) + '</div>';
|
||||
const row = document.createElement('div'); row.className = 'pb-actions';
|
||||
row.appendChild(linkBtn(SP + s.file, 'Download the video')); row.appendChild(copyBtn(s.cap, 'Copy caption'));
|
||||
[linkBtn('https://x.com/intent/tweet?text=' + enc(s.cap), 'X'), linkBtn('https://www.facebook.com/sharer/sharer.php?u=' + enc(pl), 'Facebook'), linkBtn('https://t.me/share/url?url=' + enc(pl) + '&text=' + enc(s.cap.replace(pl, '').trim()), 'Telegram'), linkBtn('https://wa.me/?text=' + enc(s.cap), 'WhatsApp')].forEach(b => row.appendChild(b));
|
||||
d.appendChild(row); ps.appendChild(d); }
|
||||
}
|
||||
const pp = $('phPosts'); pp.innerHTML = '';
|
||||
for (const p of PH_POSTS) { const extras = p.subject ? [] : [linkBtn('https://x.com/intent/tweet?text=' + enc(p.text), 'X'), linkBtn('https://t.me/share/url?url=' + enc(pl) + '&text=' + enc(p.text.replace(pl, '').trim()), 'Telegram')]; pp.appendChild(block(p.text, '<span class="lab">' + esc(p.name) + '</span>' + (p.subject ? ' <span class="muted small">Subject: ' + esc(p.subject) + '</span>' : ''), extras)); }
|
||||
const sizes = [['728x90', 'Leaderboard'], ['468x60', 'Mobile leaderboard'], ['300x250', 'Medium rectangle'], ['250x250', 'Square'], ['125x125', 'Button'], ['160x600', 'Skyscraper'], ['1200x630', 'Social card'], ['1080x1080', 'Instagram square']];
|
||||
const bg = $('phBanners'); bg.innerHTML = '';
|
||||
for (const [s, n] of sizes) { const w = s.split('x')[0], h = s.split('x')[1]; const src = 'https://polhunter.com/promo/polhunter-' + s + (Number(w) >= 300 ? '.jpg' : '.png'); const code = '<a href="' + pl + '" target="_blank" rel="noopener"><img src="' + src + '" width="' + w + '" height="' + h + '" alt="PolHunter"></a>';
|
||||
const d = document.createElement('div'); d.className = 'promo-block'; d.innerHTML = '<div class="pb-head"><span class="lab">' + s + '</span> <span class="muted small">' + esc(n) + '</span></div><div style="overflow:auto;margin:8px 0"><img src="' + src + '" alt="' + s + '" style="max-width:100%;height:auto;border-radius:8px"></div><div class="pb-text mono small" style="overflow-wrap:anywhere">' + esc(code) + '</div>';
|
||||
const row = document.createElement('div'); row.className = 'pb-actions'; row.appendChild(copyBtn(code, 'Copy embed code')); row.appendChild(linkBtn(src, 'Open image')); d.appendChild(row); bg.appendChild(d); }
|
||||
}
|
||||
// objections
|
||||
const ob = $('promoObjections');
|
||||
if (ob && ob.dataset.filled !== link) {
|
||||
|
||||
@@ -55,12 +55,22 @@
|
||||
$('shNext').hidden = false;
|
||||
}
|
||||
$('shNext').addEventListener('click', load);
|
||||
// 2026-09-16: was a native prompt() pre-filled with 'inappropriate' (one stray OK = a false report).
|
||||
// Now an inline row of reason buttons: the member picks one on purpose, or cancels.
|
||||
$('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(() => {});
|
||||
let row = document.getElementById('shReasons');
|
||||
if (row) { row.remove(); return; }
|
||||
row = document.createElement('div'); row.id = 'shReasons';
|
||||
row.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;align-items:center';
|
||||
row.innerHTML = '<span class="muted small">What is wrong?</span>' + ['broken', 'inappropriate', 'spam', 'scam', 'other'].map(r => '<button type="button" class="btn small sec" data-reason="' + r + '">' + r + '</button>').join('') + '<button type="button" class="btn small sec" data-reason="">cancel</button>';
|
||||
$('shReport').insertAdjacentElement('afterend', row);
|
||||
row.addEventListener('click', ev => {
|
||||
const b = ev.target.closest('button[data-reason]'); if (!b) return;
|
||||
const reason = b.dataset.reason; row.remove(); 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', () => {
|
||||
|
||||
@@ -53,6 +53,20 @@ nav .links a.active{color:var(--mint);background:rgba(67,232,195,.1)}
|
||||
.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}
|
||||
/* a third, quieter button for actions that are not the main move (Archive). NOT ".ghost": that is
|
||||
the giant footer wordmark and it sets color:transparent, which made the Archive button invisible
|
||||
(Marty, 2026-09-23). */
|
||||
.btn.quiet{background:transparent;color:var(--muted);border:1px solid var(--line-strong);box-shadow:none}
|
||||
.btn.quiet:hover{background:rgba(139,166,156,.1);color:var(--ink);border-color:var(--muted);box-shadow:none}
|
||||
/* the member's campaign actions sat flush against each other on desktop; the mobile rule already
|
||||
had a gap, this gives the wide layout the same room */
|
||||
/* Campaign actions (Marty, 2026-09-23): the column is narrow, so these stack. Left-aligned pills
|
||||
of different widths read as a ragged edge, and a wrapped label inside a 999px radius turns into
|
||||
a tall oval. They are a centred, even-width stack with a modest corner instead. */
|
||||
.camp-table td.act{text-align:center;vertical-align:middle}
|
||||
.camp-table td.act .btn{display:block;width:100%;min-width:118px;margin:0 0 8px;border-radius:10px;
|
||||
text-align:center;line-height:1.25;white-space:normal}
|
||||
.camp-table td.act .btn:last-child{margin-bottom:0}
|
||||
.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}
|
||||
@@ -171,12 +185,13 @@ h3{font-size:16.5px;margin:0 0 8px;font-weight:700}
|
||||
.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 42s linear infinite}
|
||||
.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%)}}
|
||||
@@ -268,6 +283,9 @@ textarea{resize:vertical;font:inherit}
|
||||
.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 a{display:flex;align-items:center;gap:11px;color:var(--muted);font:600 14px var(--disp);padding:11px 12px;border-radius:11px;text-align:left;border-left:3px solid transparent;text-decoration:none}
|
||||
.bo-menu a:hover{color:var(--ink);background:rgba(67,232,195,.05)}
|
||||
.bo-menu a 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 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)}
|
||||
@@ -298,7 +316,16 @@ textarea{resize:vertical;font:inherit}
|
||||
.bo-content{padding:18px}
|
||||
}
|
||||
/* ── promo banners ── */
|
||||
.promo-banners{display:flex;flex-direction:column;gap:14px;margin-top:12px}
|
||||
.promo-banners{display:flex;flex-direction:column;gap:10px;margin-top:12px}
|
||||
.pb-note{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 14px;padding:10px 16px 12px;font-size:13.5px;color:var(--muted);border-bottom:1px solid var(--line)}
|
||||
.pb-note b.mono{color:var(--ink);font-family:var(--mono);font-size:12.5px;word-break:break-all}
|
||||
.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)}
|
||||
@@ -377,6 +404,12 @@ textarea{resize:vertical;font:inherit}
|
||||
.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)}
|
||||
/* dismiss on the announcement cards at the top of the Overview (Marty, 2026-09-22):
|
||||
hides the card for the rest of this browser session, back at the next sign-in */
|
||||
.card-x{flex:none;align-self:center;margin:-4px -6px 0 2px;width:28px;height:28px;border-radius:50%;
|
||||
border:1px solid var(--line);background:transparent;color:var(--muted);font-size:19px;line-height:1;
|
||||
cursor:pointer;display:grid;place-items:center;padding:0}
|
||||
.card-x:hover{border-color:var(--line-strong);color:var(--ink);background:rgba(139,166,156,.1)}
|
||||
/* 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));
|
||||
@@ -418,6 +451,9 @@ textarea{resize:vertical;font:inherit}
|
||||
.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}
|
||||
@@ -441,15 +477,22 @@ textarea{resize:vertical;font:inherit}
|
||||
.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;gap:6px 14px;
|
||||
.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 .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;
|
||||
@@ -595,7 +638,7 @@ img{max-width:100%}
|
||||
.chat-day{align-self:center;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.05em;margin:6px 0}
|
||||
.chat-banner{padding:8px 14px;border-top:1px solid var(--line);color:var(--muted);font-size:12.5px;background:var(--ground2)}
|
||||
.chat-compose{display:flex;gap:8px;padding:10px;border-top:1px solid var(--line);align-items:flex-end}
|
||||
.chat-compose textarea{flex:1;resize:none;max-height:120px;background:var(--ground2);border:1px solid var(--line);
|
||||
.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}
|
||||
@@ -614,12 +657,31 @@ img{max-width:100%}
|
||||
.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)}
|
||||
@@ -649,3 +711,82 @@ img{max-width:100%}
|
||||
.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}
|
||||
/* on a phone the card is wide enough for them to share a row, so undo the desktop stack */
|
||||
.camp-table td.act .btn{flex:1 1 auto;text-align:center;display:inline-block;width:auto;min-width:0;margin:0}
|
||||
}
|
||||
|
||||
/* 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}}
|
||||
/* Overview chips drill down to My line (Marty, 2026-09-15) */
|
||||
.lt-node.lt-go{cursor:pointer;transition:border-color .15s,transform .15s} .lt-node.lt-go:hover,.lt-node.lt-go:focus-visible{border-color:var(--mint);transform:translateY(-1px);outline:none}
|
||||
.lin-row.lin-focus{box-shadow:0 0 0 2px var(--mint);border-radius:10px}
|
||||
/* paged lists: 25 rows, then a control bar that reads as a control, not more list (Marty, 2026-09-19) */
|
||||
.pgr{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin:12px 0 4px;padding:10px 12px;border:1px solid var(--line-strong);border-radius:12px;background:rgba(67,232,195,.07)}
|
||||
.pgr-n{color:var(--ink);font-size:13px} .pgr-n b{color:var(--mint);font-variant-numeric:tabular-nums}
|
||||
.pgr-b{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.pgr-pg{font-size:11px;color:var(--muted);letter-spacing:.06em;text-transform:uppercase;white-space:nowrap}
|
||||
.pgr-btn{background:var(--mint);color:var(--mint-ink);border:0;border-radius:999px;padding:8px 14px;font-weight:800;font-size:13px;cursor:pointer;white-space:nowrap}
|
||||
.pgr-btn:hover{background:var(--mint-hi)} .pgr-btn[disabled]{opacity:.35;cursor:default}
|
||||
.pipe-col .pgr{padding:8px;gap:6px;margin-top:8px} .pipe-col .pgr-btn{padding:6px 10px;font-size:12px}
|
||||
#chatThreads .pgr{margin:8px 10px}
|
||||
|
||||
/* Five Dollar Friday strip (landing) */
|
||||
.fdf{margin:18px auto 0;max-width:760px;padding:12px 16px;border-radius:12px;background:rgba(67,232,195,.08);border:1px solid rgba(67,232,195,.35);color:var(--ink,#eafffa);font-size:14px;line-height:1.5;text-align:center}
|
||||
.fdf a{color:#43e8c3;font-weight:600}
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
}
|
||||
left = info.dwell || 5;
|
||||
$('vFrame').src = info.targetUrl;
|
||||
const vo = $('vOpen'); if (vo) { vo.href = info.targetUrl; vo.hidden = false; } // a way out to the real site (mindfulmojo, 2026-09-19)
|
||||
setMsg('Watching: ' + (info.adName || 'member ad') + ' — stay on this tab.');
|
||||
$('vTimer').textContent = left + 's left';
|
||||
timer = setInterval(tick, 250);
|
||||
|
||||
@@ -41,6 +41,18 @@
|
||||
'<a href="' + String(w.socials[k]).replace(/"/g, '%22') + '" target="_blank" rel="noopener nofollow me">' + (ICON[k] || '') + '<span>' + SOC[k] + '</span></a>');
|
||||
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 = '<iframe src="https://www.youtube-nocookie.com/embed/' + yt[1] + '?rel=0" title="Intro video" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen loading="lazy"></iframe>';
|
||||
else if (vm) inner = '<iframe src="https://player.vimeo.com/video/' + vm[1] + '" title="Intro video" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen loading="lazy"></iframe>';
|
||||
else if (/\.(mp4|webm)(\?|$)/i.test(vurl)) inner = '<video src="' + vurl.replace(/"/g, '%22') + '" controls playsinline preload="metadata"></video>';
|
||||
if (inner) { bv.innerHTML = '<p class="eyebrow" style="margin:0 0 8px">A word from ' + String(w.name).replace(/[&<>]/g, '') + '</p>' + 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;
|
||||
|
||||
@@ -226,6 +226,22 @@ window.IAPWallet = (function () {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -264,5 +280,5 @@ window.IAPWallet = (function () {
|
||||
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, waitTx, disconnect, balance, address: currentAddress, activeAddress, pickAccount, walletName };
|
||||
return { connect, signIn, buy, activate, sendPol, waitTx, disconnect, balance, address: currentAddress, activeAddress, pickAccount, walletName };
|
||||
})();
|
||||
|
||||
@@ -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'); }
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,115 @@
|
||||
/* InstantAdPay session pop-up (Marty, 2026-09-24).
|
||||
|
||||
One card, once per browser session, on every page that loads common.js. It exists because the
|
||||
dashboard card was too easy to scroll past: this one is a proper overlay you have to dismiss.
|
||||
|
||||
It leads with WHAT THE SITE IS, not the promotion, because the ask was "remind them what it is
|
||||
about". Five Dollar Friday rides along underneath as the reason to act this week, and the card
|
||||
reads the live /api/friday so it says "tomorrow", "today" or nothing at all without anyone
|
||||
editing copy.
|
||||
|
||||
Rules it follows:
|
||||
- once per SESSION, not per day: sessionStorage, so closing the tab resets it
|
||||
- a new `key` (change of promo, new campaign) shows it again even in the same session
|
||||
- never on the sign-up or sign-in flow: an overlay in the middle of joining costs sign-ups
|
||||
- Escape and the backdrop both close it, and focus is trapped while it is open
|
||||
- if sessionStorage is unavailable (private mode) it simply shows once and does not throw
|
||||
*/
|
||||
(function () {
|
||||
var KEY = 'iap.welcome.v1';
|
||||
// Pages where an overlay would get in the way of something more important.
|
||||
// /join is the sign-up flow and /admin is staff only; an overlay on either is pure friction.
|
||||
var SKIP = /^\/(join|signup|register|login|auth|admin)(\/|$)/i;
|
||||
|
||||
function seen() { try { return sessionStorage.getItem(KEY) === '1'; } catch (e) { return false; } }
|
||||
function mark() { try { sessionStorage.setItem(KEY, '1'); } catch (e) {} }
|
||||
|
||||
function fridayLine(f) {
|
||||
if (!f || !f.on) return null;
|
||||
if (f.live) return { tag: 'TODAY', text: 'It is Five Dollar Friday right now. Every package of $5 or more gets <b>20% bonus credits</b>, automatically, until midnight Central.' };
|
||||
if (f.nextLabel) return { tag: f.nextLabel, text: 'Five Dollar Friday: any package of $5 or more gets <b>' + (f.pct || 20) + '% bonus credits</b>, automatically. No code, nothing to claim.' };
|
||||
return null;
|
||||
}
|
||||
|
||||
function show(f) {
|
||||
if (document.querySelector('.iap-welcome')) return;
|
||||
var fri = fridayLine(f);
|
||||
|
||||
var back = document.createElement('div');
|
||||
back.className = 'iap-welcome';
|
||||
back.setAttribute('role', 'dialog');
|
||||
back.setAttribute('aria-modal', 'true');
|
||||
back.setAttribute('aria-label', 'About InstantAdPay');
|
||||
back.style.cssText = 'position:fixed;inset:0;z-index:400;background:rgba(2,6,5,.72);' +
|
||||
'display:flex;align-items:center;justify-content:center;padding:18px;overflow:auto;' +
|
||||
'-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);';
|
||||
|
||||
var card = document.createElement('div');
|
||||
card.style.cssText = 'position:relative;width:100%;max-width:470px;background:#0b1512;color:#eef7f3;' +
|
||||
'border:1px solid rgba(67,232,195,.28);border-radius:18px;padding:26px 24px 22px;' +
|
||||
'box-shadow:0 26px 70px rgba(0,0,0,.6);font-size:15px;line-height:1.55;';
|
||||
|
||||
var html =
|
||||
'<button type="button" class="iap-w-x" aria-label="Close" style="position:absolute;top:10px;right:12px;' +
|
||||
'background:none;border:0;color:#8ba69c;font-size:26px;line-height:1;cursor:pointer;padding:4px 8px;">×</button>' +
|
||||
'<div style="letter-spacing:2px;text-transform:uppercase;font-size:11px;font-weight:700;color:#43e8c3;">InstantAdPay</div>' +
|
||||
'<h2 style="margin:8px 0 12px;font-size:25px;line-height:1.25;color:#fff;">The ad spend in your line pays you, in the same transaction.</h2>' +
|
||||
'<p style="margin:0 0 14px;color:#c8d6e2;">You buy advertising you were going to buy anyway. When someone in your line buys a package, the contract splits it and your share lands in your wallet before the page reloads. No back office, no payday to wait for.</p>' +
|
||||
'<ul style="margin:0 0 16px;padding-left:18px;color:#c8d6e2;">' +
|
||||
'<li style="margin-bottom:5px;">Every payout is on the public ledger, and you can read it yourself.</li>' +
|
||||
'<li style="margin-bottom:5px;">Free members earn ad credits by viewing ads, videos and visits.</li>' +
|
||||
'<li>Credits buy your own advertising. They are delivery, not a balance to hoard.</li>' +
|
||||
'</ul>';
|
||||
|
||||
if (fri) {
|
||||
html += '<div style="margin:0 0 16px;padding:12px 14px;border:1px solid rgba(255,178,56,.38);' +
|
||||
'background:rgba(255,178,56,.08);border-radius:12px;color:#eef7f3;">' +
|
||||
'<div style="font-weight:800;color:#ffb238;font-size:12px;letter-spacing:1.4px;text-transform:uppercase;margin-bottom:4px;">' + fri.tag + '</div>' +
|
||||
'<div>' + fri.text + '</div></div>';
|
||||
}
|
||||
|
||||
html += '<div style="display:flex;gap:9px;flex-wrap:wrap;">' +
|
||||
'<a class="iap-w-go" href="/my#buy" style="flex:1 1 auto;text-align:center;text-decoration:none;background:linear-gradient(180deg,#5ff0cf,#43e8c3);color:#04120e;font-weight:800;padding:12px 16px;border-radius:11px;">See the packages</a>' +
|
||||
'<a class="iap-w-go" href="/ledger" style="flex:0 1 auto;text-align:center;text-decoration:none;border:1px solid rgba(67,232,195,.4);color:#43e8c3;font-weight:700;padding:12px 16px;border-radius:11px;">Live ledger</a>' +
|
||||
'</div>';
|
||||
|
||||
card.innerHTML = html;
|
||||
back.appendChild(card);
|
||||
document.body.appendChild(back);
|
||||
mark();
|
||||
|
||||
var prev = document.activeElement;
|
||||
var closeBtn = card.querySelector('.iap-w-x');
|
||||
function close() {
|
||||
back.remove();
|
||||
document.removeEventListener('keydown', onKey);
|
||||
try { if (prev && prev.focus) prev.focus(); } catch (e) {}
|
||||
}
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') { close(); return; }
|
||||
if (e.key !== 'Tab') return;
|
||||
var f = card.querySelectorAll('button,a[href]');
|
||||
if (!f.length) return;
|
||||
var first = f[0], last = f[f.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
closeBtn.addEventListener('click', close);
|
||||
back.addEventListener('click', function (e) { if (e.target === back) close(); });
|
||||
// following a link is a deliberate exit, so let it through but tidy up
|
||||
card.querySelectorAll('.iap-w-go').forEach(function (a) { a.addEventListener('click', close); });
|
||||
document.addEventListener('keydown', onKey);
|
||||
try { closeBtn.focus(); } catch (e) {}
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (seen() || SKIP.test(location.pathname)) return;
|
||||
// the Friday state decides one paragraph; a failure there must not stop the card
|
||||
fetch('/api/friday').then(function (r) { return r.ok ? r.json() : null; })
|
||||
.catch(function () { return null; })
|
||||
.then(function (f) { show(f); });
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
|
||||
else boot();
|
||||
})();
|
||||
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 180 KiB |
@@ -1,148 +1,148 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>The contract | InstantAdPay</title>
|
||||
<meta name="description" content="Plain-language review of the InstantAdPay settlement contract: what it does, what nobody can change, what the operator can and cannot touch, and how to verify all of it yourself.">
|
||||
<link rel="canonical" href="https://instantadpay.com/contract">
|
||||
<meta property="og:type" content="website"><meta property="og:site_name" content="InstantAdPay">
|
||||
<meta property="og:url" content="https://instantadpay.com/contract">
|
||||
<meta property="og:title" content="InstantAdPay — the contract, in plain language">
|
||||
<meta property="og:description" content="What the settlement contract does, what nobody can change, and how to verify all of it yourself.">
|
||||
<meta property="og:image" content="https://instantadpay.com/banners/iap-hero-1200x630.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="InstantAdPay — the contract, in plain language">
|
||||
<meta name="twitter:description" content="Immutable 50-20-10 splits, no withdrawal button, verify it yourself.">
|
||||
<meta name="twitter:image" content="https://instantadpay.com/banners/iap-hero-1200x630.png">
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero" style="padding-bottom:24px">
|
||||
<h1>The contract, <em>in plain language</em>.</h1>
|
||||
<p class="lead">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.</p>
|
||||
<p>
|
||||
<span class="badge">address: <span class="mono" id="cAddr">…</span></span>
|
||||
<span class="badge" id="cChain">…</span>
|
||||
</p>
|
||||
<p style="margin-top:16px">
|
||||
<a class="btn sec small" id="lnkExplorer" target="_blank" rel="noopener">Raw contract on the explorer ↗</a>
|
||||
<a class="btn sec small" id="lnkSource" target="_blank" rel="noopener">Verified source code ↗</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="card">
|
||||
<h3>The six laws the code enforces</h3>
|
||||
<ul class="checks">
|
||||
<li><b>It never holds funds.</b> 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.</li>
|
||||
<li><b>The compensation rules are constants.</b> 50 percent, 20 percent, 10 percent, 20 percent platform. They are compiled into the bytecode. No function exists to change them.</li>
|
||||
<li><b>No upgrade path, no pause switch, no self-destruct.</b> The deployed bytecode is the program forever.</li>
|
||||
<li><b>Purchased credits only ever go down by delivering your ads.</b> No function reduces them for any other reason, and nothing can mint them except a purchase.</li>
|
||||
<li><b>Qualification is earned, never bought.</b> Deeper levels unlock only by referring real buyers of $20 or more. No spend-based shortcuts exist.</li>
|
||||
<li><b>Everything is observable.</b> Every state change emits a public event. The website is a mirror of the chain, never the source of truth for money.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="grid c2">
|
||||
<div class="card">
|
||||
<h3>What the operator CAN do</h3>
|
||||
<ul class="checks">
|
||||
<li>Add ad packages to the catalog (price floor $1, ceiling $500)</li>
|
||||
<li>Queue a price change, which waits behind a public 24-hour timelock before anyone can apply it</li>
|
||||
<li>Retire a package from sale, and reactivate it later</li>
|
||||
<li>Rotate the fee-receiver, ad-engine, and owner addresses (key-loss insurance)</li>
|
||||
<li>The ad engine can burn credits, but only as your campaigns consume delivery</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>What the operator CANNOT do</h3>
|
||||
<ul class="checks">
|
||||
<li>Change any split percentage or qualification threshold</li>
|
||||
<li>Pause, upgrade, or replace the contract</li>
|
||||
<li>Hold, redirect, or claw back anyone's payout</li>
|
||||
<li>Mint credits, take credits, or touch anyone's membership record</li>
|
||||
<li>Move a price outside the $1 to $500 bounds, or skip the 24-hour notice</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Where every purchase goes</h3>
|
||||
<div class="split">
|
||||
<div class="s50">50%<br>direct sponsor</div>
|
||||
<div class="s20">20%<br>level 2</div>
|
||||
<div class="s10">10%<br>level 3</div>
|
||||
<div class="sa">20%<br>platform</div>
|
||||
</div>
|
||||
<p class="muted small">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 <a href="/ledger">live ledger</a>.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid c2">
|
||||
<div class="card">
|
||||
<h3>Dollar prices, POL settlement</h3>
|
||||
<p class="muted small">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.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Nobody can stall it</h3>
|
||||
<p class="muted small">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.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>How it was tested</h3>
|
||||
<p class="muted small">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.</p>
|
||||
</div>
|
||||
|
||||
<div class="closer">
|
||||
<div>
|
||||
<h2>Do not trust this page. Check it.</h2>
|
||||
<p class="muted" style="margin:0 0 22px">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.</p>
|
||||
<a class="btn" id="lnkSource2" target="_blank" rel="noopener">Read the verified source</a>
|
||||
<a class="btn sec" href="/ledger">Open the live ledger</a>
|
||||
</div>
|
||||
<div class="mockup">
|
||||
<div class="bar"><i></i><i></i><i></i><span class="addr" id="mockAddr">contract</span></div>
|
||||
<div class="body">
|
||||
<div class="mrow"><span>compiler check</span><span>exact match</span></div>
|
||||
<div class="mrow"><span>bytecode check</span><span>exact match</span></div>
|
||||
<div class="mrow dim"><span>upgrade path</span><span>none</span></div>
|
||||
<div class="mrow dim"><span>pause switch</span><span>none</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div>InstantAdPay · <a href="/">how it works</a> · <a href="/ledger">live ledger</a></div>
|
||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260910c"></script>
|
||||
<script src="/assets/contract.js?v=20260908p"></script>
|
||||
<script src="/assets/chat.js?v=20260906m"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>The contract | InstantAdPay</title>
|
||||
<meta name="description" content="Plain-language review of the InstantAdPay settlement contract: what it does, what nobody can change, what the operator can and cannot touch, and how to verify all of it yourself.">
|
||||
<link rel="canonical" href="https://instantadpay.com/contract">
|
||||
<meta property="og:type" content="website"><meta property="og:site_name" content="InstantAdPay">
|
||||
<meta property="og:url" content="https://instantadpay.com/contract">
|
||||
<meta property="og:title" content="InstantAdPay — the contract, in plain language">
|
||||
<meta property="og:description" content="What the settlement contract does, what nobody can change, and how to verify all of it yourself.">
|
||||
<meta property="og:image" content="https://instantadpay.com/banners/iap-hero-1200x630.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="InstantAdPay — the contract, in plain language">
|
||||
<meta name="twitter:description" content="Immutable 50-20-10 splits, no withdrawal button, verify it yourself.">
|
||||
<meta name="twitter:image" content="https://instantadpay.com/banners/iap-hero-1200x630.png">
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero" style="padding-bottom:24px">
|
||||
<h1>The contract, <em>in plain language</em>.</h1>
|
||||
<p class="lead">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.</p>
|
||||
<p>
|
||||
<span class="badge">address: <span class="mono" id="cAddr">…</span></span>
|
||||
<span class="badge" id="cChain">…</span>
|
||||
</p>
|
||||
<p style="margin-top:16px">
|
||||
<a class="btn sec small" id="lnkExplorer" target="_blank" rel="noopener">Raw contract on the explorer ↗</a>
|
||||
<a class="btn sec small" id="lnkSource" target="_blank" rel="noopener">Verified source code ↗</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="card">
|
||||
<h3>The six laws the code enforces</h3>
|
||||
<ul class="checks">
|
||||
<li><b>It never holds funds.</b> 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.</li>
|
||||
<li><b>The compensation rules are constants.</b> 50 percent, 20 percent, 10 percent, 20 percent platform. They are compiled into the bytecode. No function exists to change them.</li>
|
||||
<li><b>No upgrade path, no pause switch, no self-destruct.</b> The deployed bytecode is the program forever.</li>
|
||||
<li><b>Purchased credits only ever go down by delivering your ads.</b> No function reduces them for any other reason, and nothing can mint them except a purchase.</li>
|
||||
<li><b>Qualification is earned, never bought.</b> Deeper levels unlock only by referring real buyers of $20 or more. No spend-based shortcuts exist.</li>
|
||||
<li><b>Everything is observable.</b> Every state change emits a public event. The website is a mirror of the chain, never the source of truth for money.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="grid c2">
|
||||
<div class="card">
|
||||
<h3>What the operator CAN do</h3>
|
||||
<ul class="checks">
|
||||
<li>Add ad packages to the catalog (price floor $1, ceiling $500)</li>
|
||||
<li>Queue a price change, which waits behind a public 24-hour timelock before anyone can apply it</li>
|
||||
<li>Retire a package from sale, and reactivate it later</li>
|
||||
<li>Rotate the fee-receiver, ad-engine, and owner addresses (key-loss insurance)</li>
|
||||
<li>The ad engine can burn credits, but only as your campaigns consume delivery</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>What the operator CANNOT do</h3>
|
||||
<ul class="checks">
|
||||
<li>Change any split percentage or qualification threshold</li>
|
||||
<li>Pause, upgrade, or replace the contract</li>
|
||||
<li>Hold, redirect, or claw back anyone's payout</li>
|
||||
<li>Mint credits, take credits, or touch anyone's membership record</li>
|
||||
<li>Move a price outside the $1 to $500 bounds, or skip the 24-hour notice</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Where every purchase goes</h3>
|
||||
<div class="split">
|
||||
<div class="s50">50%<br>direct sponsor</div>
|
||||
<div class="s20">20%<br>level 2</div>
|
||||
<div class="s10">10%<br>level 3</div>
|
||||
<div class="sa">20%<br>platform</div>
|
||||
</div>
|
||||
<p class="muted small">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 <a href="/ledger">live ledger</a>.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid c2">
|
||||
<div class="card">
|
||||
<h3>Dollar prices, POL settlement</h3>
|
||||
<p class="muted small">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.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Nobody can stall it</h3>
|
||||
<p class="muted small">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.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>How it was tested</h3>
|
||||
<p class="muted small">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.</p>
|
||||
</div>
|
||||
|
||||
<div class="closer">
|
||||
<div>
|
||||
<h2>Do not trust this page. Check it.</h2>
|
||||
<p class="muted" style="margin:0 0 22px">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.</p>
|
||||
<a class="btn" id="lnkSource2" target="_blank" rel="noopener">Read the verified source</a>
|
||||
<a class="btn sec" href="/ledger">Open the live ledger</a>
|
||||
</div>
|
||||
<div class="mockup">
|
||||
<div class="bar"><i></i><i></i><i></i><span class="addr" id="mockAddr">contract</span></div>
|
||||
<div class="body">
|
||||
<div class="mrow"><span>compiler check</span><span>exact match</span></div>
|
||||
<div class="mrow"><span>bytecode check</span><span>exact match</span></div>
|
||||
<div class="mrow dim"><span>upgrade path</span><span>none</span></div>
|
||||
<div class="mrow dim"><span>pause switch</span><span>none</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div>InstantAdPay · <a href="/">how it works</a> · <a href="/ledger">live ledger</a></div>
|
||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260923b"></script><script src="/assets/welcome.js?v=20260923b"></script>
|
||||
<script src="/assets/contract.js?v=20260908p"></script>
|
||||
<script src="/assets/chat.js?v=20260906m"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Disclaimer | InstantAdPay</title>
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
</head>
|
||||
<body>
|
||||
<div id="nav"></div>
|
||||
<section><div class="wrap" style="max-width:820px">
|
||||
<div class="sectionhead" style="text-align:left"><h2>Risk & Earnings Disclaimer</h2><p class="muted small">Last updated: September 2026</p></div>
|
||||
<div class="card">
|
||||
<h3>No income guarantee</h3>
|
||||
<p class="muted small">InstantAdPay 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.</p>
|
||||
<h3>Not financial or legal advice</h3>
|
||||
<p class="muted small">Nothing on the Platform is investment, financial, tax, or legal advice. Do your own research and consult a professional before spending money.</p>
|
||||
<h3>Crypto risk</h3>
|
||||
<p class="muted small">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.</p>
|
||||
<h3>Live on Polygon</h3>
|
||||
<p class="muted small">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.</p>
|
||||
<h3>Advertising</h3>
|
||||
<p class="muted small">Ads are member-created and auto-approved for speed. InstantAdPay does not endorse and is not responsible for advertised products, sites, or claims. Use your judgment, and report anything broken or inappropriate.</p>
|
||||
<h3>Your responsibility</h3>
|
||||
<p class="muted small">You decide whether, and how much, to spend. Never spend more than you can afford to lose.</p>
|
||||
</div>
|
||||
</div></section>
|
||||
<script src="/assets/common.js?v=20260910c"></script>
|
||||
<script src="/assets/legal.js?v=20260908a"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Disclaimer | InstantAdPay</title>
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
</head>
|
||||
<body>
|
||||
<div id="nav"></div>
|
||||
<section><div class="wrap" style="max-width:820px">
|
||||
<div class="sectionhead" style="text-align:left"><h2>Risk & Earnings Disclaimer</h2><p class="muted small">Last updated: September 2026</p></div>
|
||||
<div class="card">
|
||||
<h3>No income guarantee</h3>
|
||||
<p class="muted small">InstantAdPay 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.</p>
|
||||
<h3>Not financial or legal advice</h3>
|
||||
<p class="muted small">Nothing on the Platform is investment, financial, tax, or legal advice. Do your own research and consult a professional before spending money.</p>
|
||||
<h3>Crypto risk</h3>
|
||||
<p class="muted small">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.</p>
|
||||
<h3>Live on Polygon</h3>
|
||||
<p class="muted small">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.</p>
|
||||
<h3>Advertising</h3>
|
||||
<p class="muted small">Ads are member-created and auto-approved for speed. InstantAdPay does not endorse and is not responsible for advertised products, sites, or claims. Use your judgment, and report anything broken or inappropriate.</p>
|
||||
<h3>Your responsibility</h3>
|
||||
<p class="muted small">You decide whether, and how much, to spend. Never spend more than you can afford to lose.</p>
|
||||
</div>
|
||||
</div></section>
|
||||
<script src="/assets/common.js?v=20260923b"></script><script src="/assets/welcome.js?v=20260923b"></script>
|
||||
<script src="/assets/legal.js?v=20260908a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>How earning works: credits and POL payouts | InstantAdPay</title>
|
||||
<meta name="description" content="The two ways members earn on InstantAdPay: ad credits (the daily set, the claim streak, visits, videos, inbox ads, sign-in bonus, milestones) and POL paid to your wallet from ad packages bought in your line, on chain, in the same transaction.">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260912b">
|
||||
<style>
|
||||
.eg{max-width:820px}
|
||||
.eg h2{font-size:23px;margin:40px 0 10px;padding-top:18px;border-top:1px solid var(--line)}
|
||||
.eg p,.eg li{max-width:68ch}
|
||||
.eg ul,.eg ol{margin:0 0 14px 20px;padding:0} .eg li{margin:7px 0}
|
||||
table{border-collapse:collapse;width:100%;max-width:560px;margin:10px 0 16px;font-variant-numeric:tabular-nums;font-size:15px}
|
||||
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line)} th{color:var(--muted);font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;font-weight:500}
|
||||
td:last-child,th:last-child{text-align:right}
|
||||
.rule{border-left:4px solid var(--mint);background:rgba(67,232,195,.07);padding:14px 18px;border-radius:0 12px 12px 0;margin:0 0 20px}
|
||||
.rule b{font-family:var(--disp);display:block;margin-bottom:4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap eg">
|
||||
<section class="hero" style="padding:60px 0 14px">
|
||||
<p class="eyebrow">Member guide</p>
|
||||
<h1>How earning works: <em>credits</em>, and POL to your wallet.</h1>
|
||||
<p class="lead">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.</p>
|
||||
</section>
|
||||
|
||||
<div class="rule"><b>This page is about credits, which every member earns.</b> 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.</div>
|
||||
<div class="rule"><b>One credit is one cent of ad delivery.</b> Credits are not money and cannot be withdrawn. They buy impressions, clicks and visits for your own campaigns across InstantAdPay and the partner network. Payouts in POL come only from packages people in your line buy.</div>
|
||||
|
||||
<h2>1. The daily set</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>2. The claim streak</h2>
|
||||
<p>The claim pays more the more days in a row you claim it. Miss a day and it restarts at day 1.</p>
|
||||
<table>
|
||||
<tr><th>Consecutive day</th><th>Claim pays</th></tr>
|
||||
<tr><td>Day 1</td><td>5 credits</td></tr>
|
||||
<tr><td>Day 2</td><td>7 credits</td></tr>
|
||||
<tr><td>Day 3 and on</td><td>10 credits</td></tr>
|
||||
<tr><td>Every 7th day in a row</td><td>25 credits</td></tr>
|
||||
</table>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>3. After the set: verified visits</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>4. Videos and inbox ads</h2>
|
||||
<ul>
|
||||
<li><b>Videos.</b> 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.</li>
|
||||
<li><b>Inbox solo ads.</b> Earn credits › Inbox Ads. Open the message, visit the advertiser's link, then claim 2 credits. Once per message.</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. The sign-in bonus and milestones</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>6. Now spend it</h2>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>7. The other earning: POL paid to your wallet</h2>
|
||||
<p>Everything above is about <b>credits</b>, the currency that runs your advertising. The second kind of earning is <b>POL</b>, real money sent to your own wallet, and it comes from one place: ad packages bought by people in your line.</p>
|
||||
<p>When anyone buys a package, the smart contract splits the price in the same transaction: <b>50%</b> to the buyer's sponsor (level 1), <b>20%</b> to the sponsor's sponsor (level 2), <b>10%</b> to the one above that (level 3), and 20% to the platform. There is no balance to withdraw and no request to make. The POL lands in the wallet on your account before the buyer's page has finished refreshing, and every payout carries a link you can open on Polygon.</p>
|
||||
<h3>What has to be true for a share to reach you</h3>
|
||||
<ul>
|
||||
<li><b>Payouts are switched on.</b> Wallet tab: link your wallet and switch on payouts, which puts your member number on the contract. Until then a share meant for you passes up to the next payable person above you, and it is never paid to you later.</li>
|
||||
<li><b>Level 1 pays from the moment payouts are on.</b> Every direct referral's package pays you 50%, whatever the size.</li>
|
||||
<li><b>Level 2 opens at two qualifying buyers, level 3 at five.</b> A qualifying buyer is a direct referral who bought a package of $20 or more. Qualification never expires and cannot be bought. It is counted at the block the purchase happens in, so a buy that lands forty minutes before you qualify is passed up, not held.</li>
|
||||
<li><b>Passed-up shares go to the next qualified person above.</b> That is why the people who qualify early collect what the people below them are not yet set up to receive.</li>
|
||||
</ul>
|
||||
<h3>Who is your sponsor</h3>
|
||||
<p>The invite link you opened most recently before creating your account sets your sponsor, and it locks the moment the account exists. Linked positions from Qualified Start count toward your badges, and their purchases pay your main position at level 1.</p>
|
||||
<h3>Where to see it</h3>
|
||||
<p><a href="/my#earnings">Earnings</a> shows three cards: payouts sent to your wallet, referral events in your line, and your purchases, every row with its Polygon link. If a share you expected is missing, use <b>Trace a payment</b> on that tab: it shows, purchase by purchase, who was paid at each level and why a level was skipped. The usual answers are not yet qualified at that block, a Qualified Start position buying under its own main wallet, or payouts not switched on. The <a href="/ledger">live ledger</a> shows the whole network's payouts as they happen.</p>
|
||||
|
||||
<p class="muted small" style="margin-top:30px">No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. InstantAdPay sells advertising; it is not an investment.</p>
|
||||
<footer>
|
||||
<div>InstantAdPay · <a href="/my#earn">Earn credits</a> · <a href="/my#campaigns">Campaigns</a> · <a href="/my#training">back to Training</a></div>
|
||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees.</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260923b"></script><script src="/assets/welcome.js?v=20260923b"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Five Dollar Friday: 20% bonus credits on every $5+ ad package | InstantAdPay</title>
|
||||
<meta name="description" content="Every Friday (US Central time), any InstantAdPay ad package from $5 up earns 20% bonus credits the moment it settles. Every pack pays its sponsor line in the same transaction. Join free by email.">
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<meta property="og:type" content="website"><meta property="og:site_name" content="InstantAdPay">
|
||||
<meta property="og:title" content="Five Dollar Friday: 20% bonus credits on every $5+ ad package">
|
||||
<meta property="og:description" content="Every Friday, US Central time, midnight to midnight. Any ad package from $5 up earns 20% bonus credits, added the moment the purchase settles. Join free by email.">
|
||||
<meta property="og:image" content="https://instantadpay.com/banners/iap-five-dollar-friday-v2-1200x630.jpg">
|
||||
<meta property="og:url" content="https://instantadpay.com/friday">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260912b">
|
||||
<style>
|
||||
.fr{max-width:860px}
|
||||
.fr .hero{padding:44px 0 10px}
|
||||
.fr-banner{display:block;width:100%;max-width:860px;border-radius:16px;border:1px solid var(--line);margin:18px 0 14px}
|
||||
.fr-state{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px;padding:14px 18px;border-radius:14px;border:1px solid var(--line);background:rgba(67,232,195,.06);margin:0 0 26px;font-size:15px}
|
||||
.fr-state.live{border-color:rgba(255,178,56,.55);background:rgba(255,178,56,.09)}
|
||||
.fr-tag{font-family:var(--mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;font-weight:700;padding:4px 9px;border-radius:999px;background:var(--mint);color:#04120e}
|
||||
.fr-state.live .fr-tag{background:#ffb238}
|
||||
.fr-state b{font-family:var(--disp)}
|
||||
.fr-num{font-family:var(--mono);font-variant-numeric:tabular-nums}
|
||||
.fr-steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin:8px 0 26px}
|
||||
.fr-step{border:1px solid var(--line);border-radius:14px;padding:16px 18px;background:rgba(255,255,255,.02)}
|
||||
.fr-step .n{font-family:var(--mono);font-size:11px;letter-spacing:.12em;color:var(--mint);text-transform:uppercase}
|
||||
.fr-step h3{font-family:var(--disp);font-size:17px;margin:6px 0 6px}
|
||||
.fr-step p{margin:0;font-size:14.5px;color:var(--muted)}
|
||||
.fr-cta{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:6px 0 30px}
|
||||
.fr-cta .btn.big{font-size:17px;padding:14px 22px}
|
||||
.fr-rule{border-left:4px solid var(--mint);background:rgba(67,232,195,.07);padding:14px 18px;border-radius:0 12px 12px 0;margin:0 0 22px;font-size:14.5px}
|
||||
.fr-share{border:1px dashed var(--line);border-radius:14px;padding:16px 18px;margin:0 0 30px;font-size:14.5px;color:var(--muted)}
|
||||
.fr-share b{color:var(--ink)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap fr">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Every Friday · US Central time · midnight to midnight</p>
|
||||
<h1>Five Dollar Friday: <em>20% bonus credits</em> on every $5+ ad package.</h1>
|
||||
<p class="lead">Buy any InstantAdPay ad package from $5 up on a Friday and 20% extra credits land the moment the purchase settles. No code, nothing to claim. And like every pack, it pays the sponsor line in the same transaction.</p>
|
||||
</section>
|
||||
|
||||
<img class="fr-banner" src="/banners/iap-five-dollar-friday-v2-1200x630.jpg" alt="Five Dollar Friday: 20% bonus credits on every $5+ ad package. Fridays 12:00 AM to 11:59 PM US Central time." width="1200" height="630">
|
||||
|
||||
<div class="fr-state" id="frState"><span class="fr-tag">Checking</span><span>Loading today’s status…</span></div>
|
||||
|
||||
<div class="fr-cta">
|
||||
<a class="btn big" id="frJoin" href="/join?v=friday">Join free by email</a>
|
||||
<a class="btn sec" href="/my#buy">Already a member? Get your pack</a>
|
||||
</div>
|
||||
|
||||
<div class="fr-steps">
|
||||
<div class="fr-step"><div class="n">Step 1</div><h3>Join free, by email.</h3><p>Email first, wallet later, inside your account. Nothing to install to look around.</p></div>
|
||||
<div class="fr-step"><div class="n">Step 2</div><h3>Buy any pack from $5 on a Friday.</h3><p>Central time, midnight to midnight. The 20% bonus is added to your credits the moment the purchase settles, and labelled in your credit log.</p></div>
|
||||
<div class="fr-step"><div class="n">Step 3</div><h3>Your pack pays the line above you. Theirs pays you.</h3><p>Every package is one Polygon transaction that splits to the sponsor line instantly. Public ledger, nothing held, nothing pending.</p></div>
|
||||
</div>
|
||||
|
||||
<div class="fr-rule"><b>What a credit is.</b> One credit is one cent of ad delivery across the network: banners, text ads, video, verified visits, solo ads and more. Credits are not money and cannot be withdrawn. They buy advertising for your own offers. The POL the contract pays to wallets is separate and comes only from packages bought in your line.</div>
|
||||
|
||||
<div class="fr-share" id="frShare"><b>Members: this page is your Friday promo tool.</b> Add <span class="fr-num">?ref=yourusername</span> to this address and anyone who joins from it is your referral, the same as your invite link. Banners, ready-made posts and your exact link are in <a href="/my#promo">Promo tools</a>.</div>
|
||||
|
||||
<p class="muted small" style="margin-top:10px">No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. InstantAdPay sells advertising; it is not an investment product.</p>
|
||||
<footer>
|
||||
<div>InstantAdPay · <a href="/">Home</a> · <a href="/ledger">Live ledger</a> · <a href="/earning">How earning works</a></div>
|
||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees.</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/friday.js?v=20260925a"></script>
|
||||
<script src="/assets/common.js?v=20260923b"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
google-site-verification: google2d3fd5448dc153ef.html
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="google-site-verification" content="9Qh7dv8AYdNjzIySFfcY975QiJRov_Zju2kkgEZoZik">
|
||||
<title>InstantAdPay: advertise and earn, locked in code</title>
|
||||
<meta name="description" content="Advertise and earn instantly. Real ad packages with instant on-chain settlement — every purchase pays the sponsor line in the same Polygon transaction, verifiable by anyone on the live ledger. Seven ad formats, earn credits back for your attention.">
|
||||
<link rel="canonical" href="https://instantadpay.com/">
|
||||
@@ -20,7 +21,7 @@
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260923e">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -58,6 +59,7 @@
|
||||
<div class="ctas">
|
||||
<a class="btn" href="/my">Join free</a>
|
||||
<a class="btn sec" href="/ledger">Watch payments land live</a>
|
||||
<a class="btn sec" href="#overview-video">Watch the 4-minute overview</a>
|
||||
</div>
|
||||
<p class="small muted" id="sponsorLine" hidden style="margin-top:20px"></p>
|
||||
<div class="stats">
|
||||
@@ -67,12 +69,25 @@
|
||||
<div class="stat"><div class="n" id="stPayouts">–</div><div class="l">Instant payouts</div></div>
|
||||
</div>
|
||||
<p class="hero-note">every number above is read from the blockchain, not a marketing database</p>
|
||||
<div class="fdf" id="fdfStrip" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticker" hidden id="ticker"><div class="inner" id="tickerInner"></div></div>
|
||||
|
||||
<section>
|
||||
<section id="overview-video" style="scroll-margin-top:16px">
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>The whole platform in four minutes</h2>
|
||||
<p>What it is, what you get free, how the ads run, how the money moves, and how a line gets built. Watch this first.</p>
|
||||
</div>
|
||||
<div style="max-width:960px;margin:0 auto;border-radius:14px;overflow:hidden;box-shadow:0 12px 48px rgba(0,0,0,.45);background:#000">
|
||||
<video controls preload="metadata" playsinline style="display:block;width:100%;aspect-ratio:16/9" poster="https://coolify-saasytop.nyc3.digitaloceanspaces.com/training/platform-overview.jpg?v=20260916a" src="https://coolify-saasytop.nyc3.digitaloceanspaces.com/training/platform-overview.mp4?v=20260916a"></video>
|
||||
</div>
|
||||
<p class="small muted" style="text-align:center;margin-top:12px">No income is promised. Every figure in the video is a rule of the contract.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section id="how">
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>Instant payments without compromise</h2>
|
||||
@@ -222,12 +237,11 @@
|
||||
<p class="muted">Levels unlock by performance, never by payment. Refer buyers, and their
|
||||
shares of every future purchase route to you automatically.</p>
|
||||
<div class="chips">
|
||||
<button class="chip-t on" data-lvl="1" type="button">Level 1: open</button>
|
||||
<button class="chip-t on" data-lvl="1" type="button">Level 1: activate</button>
|
||||
<button class="chip-t" data-lvl="2" type="button">Level 2: 2 buyers</button>
|
||||
<button class="chip-t" data-lvl="3" type="button">Level 3: 5 buyers</button>
|
||||
</div>
|
||||
<p class="muted" id="lvlDesc" style="min-height:72px">Level 1 is open to every member: your direct
|
||||
referrals each pay you 50 percent of every package they ever buy, straight to your wallet.</p>
|
||||
<p class="muted" id="lvlDesc" style="min-height:72px">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.</p>
|
||||
<ul class="checks">
|
||||
<li>No withdrawal requests, ever. Every payment sends straight to your wallet.</li>
|
||||
<li>Unqualified shares visibly pass up to the next qualified person</li>
|
||||
@@ -303,6 +317,27 @@
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<div class="grid c2" style="margin-bottom:18px">
|
||||
<div class="card">
|
||||
<h3>Credits: everyone earns these</h3>
|
||||
<p class="muted small" style="margin:0 0 8px">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.</p>
|
||||
<ul class="checks">
|
||||
<li>Earned free by viewing ads, watching videos, verified visits, inbox ads and the daily sign-in</li>
|
||||
<li>Welcome credits on day one, badge bonuses as your team grows</li>
|
||||
<li>Spent on your own banner, text, video and solo campaigns</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>POL: activated members earn this</h3>
|
||||
<p class="muted small" style="margin:0 0 8px">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.</p>
|
||||
<ul class="checks">
|
||||
<li>Activate: the $20 starter package (2,000 credits to advertise with) and payouts switched on from your wallet</li>
|
||||
<li>Level 1 pays 50 percent of every package your direct referrals buy, from the day you are activated</li>
|
||||
<li>Levels 2 and 3 open when 2, then 5, of your referrals buy a $20 or larger package</li>
|
||||
<li>If you are not activated when someone in your line buys, that share passes up to the next member above you who is</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid c2">
|
||||
<div class="card">
|
||||
<h3>Free membership includes</h3>
|
||||
@@ -314,7 +349,7 @@
|
||||
<li>Your own shareable profile page with a scannable join QR code</li>
|
||||
<li>Achievement badges and credit bonuses as your team grows</li>
|
||||
<li>Your personal referral link, working from day one</li>
|
||||
<li>Earnings from day one on your referrals' package purchases</li>
|
||||
<li>Your invite link and line from day one; activate with the $20 starter package to earn POL on your referrals' purchases</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
@@ -331,7 +366,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section id="packages">
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>The ad packages</h2>
|
||||
@@ -461,9 +496,9 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="/assets/common.js?v=20260910c"></script>
|
||||
<script src="/assets/common.js?v=20260923b"></script><script src="/assets/welcome.js?v=20260923b"></script>
|
||||
<script src="/assets/wallet.js?v=20260909c"></script>
|
||||
<script src="/assets/home.js?v=20260906m"></script>
|
||||
<script src="/assets/home.js?v=20260920fdf"></script>
|
||||
<script src="/assets/chat.js?v=20260906m"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,133 +1,163 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="robots" content="noindex,follow">
|
||||
<title>You're invited | InstantAdPay</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
<style>
|
||||
.jn-nav{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:16px 0}
|
||||
.jn-hero{padding:14px 0 4px;text-align:left}
|
||||
.jn-brand{font-family:var(--disp);font-weight:800;font-size:20px;margin:0 0 4px;color:var(--ink)}
|
||||
.jn-brand em{font-style:normal;color:var(--mint)}
|
||||
.jn-brand span{font-weight:500;font-size:14px;color:var(--muted)}
|
||||
.jn-strip{margin-top:22px}
|
||||
.jn-strip .jn-chips{margin:0}
|
||||
.jn-hero h1{font-size:clamp(26px,3.6vw,40px);line-height:1.08;margin:4px 0 8px;max-width:760px;text-align:left;letter-spacing:-.015em}
|
||||
.jn-hero h1 em{font-style:normal;color:var(--mint)}
|
||||
.jn-hero p.lead{margin:0 0 12px;text-align:left;max-width:620px;font-size:15.5px}
|
||||
.jn-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:22px;align-items:start;margin-top:4px}
|
||||
@media(max-width:860px){.jn-grid{grid-template-columns:1fr}}
|
||||
.jn-cap{border-color:var(--mint);box-shadow:0 0 60px rgba(67,232,195,.12);position:sticky;top:18px}
|
||||
@media(max-width:860px){.jn-cap{position:static}}
|
||||
.jn-grid>div:first-child .mockup{margin:0}
|
||||
.jn-cap h3{margin:0 0 6px}
|
||||
.jn-cap input{width:100%}
|
||||
.jn-cap .btn{width:100%;text-align:center}
|
||||
.jn-chips{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 18px}
|
||||
.jn-chips span{border:1px solid var(--line-strong);border-radius:999px;padding:6px 14px;font-size:13px;color:var(--muted)}
|
||||
.jn-chips span b{color:var(--mint);font-weight:700}
|
||||
.jn-spon{display:flex;align-items:center;gap:10px;margin:0 0 6px}
|
||||
.jn-spon img{width:34px;height:34px;border-radius:50%;object-fit:cover;border:1px solid var(--line-strong)}
|
||||
.jn-full{margin-top:26px}
|
||||
.jn-h{display:flex;justify-content:space-between;align-items:baseline;gap:16px;flex-wrap:wrap;margin:0 0 14px}
|
||||
.jn-h h2{font-size:26px;margin:0}
|
||||
.jn-h p{margin:0;font-size:14.5px}
|
||||
.jn-steps{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
|
||||
.jn-steps .card h3{font-size:19px;margin:8px 0 8px;min-height:2.4em}
|
||||
.jn-steps .card p{margin:0}
|
||||
@media(max-width:700px){.jn-steps{grid-template-columns:1fr}}
|
||||
.jn-steps .card{margin:0}
|
||||
.jn-steps .n{font-family:var(--mono);color:var(--mint);font-size:12px;letter-spacing:.1em}
|
||||
.jn-ladder{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}
|
||||
@media(max-width:900px){.jn-ladder{grid-template-columns:repeat(3,1fr)}}
|
||||
@media(max-width:560px){.jn-ladder{grid-template-columns:repeat(2,1fr)}}
|
||||
.jn-pk{border:1px solid var(--line);border-radius:12px;padding:14px;text-align:center;background:var(--panel)}
|
||||
.jn-pk .p{font-family:var(--disp);font-size:26px;font-weight:800}
|
||||
.jn-pk .c{color:var(--mint);font-weight:700;font-size:13.5px}
|
||||
.jn-pk .n{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.1em}
|
||||
.jn-foot{color:var(--muted);font-size:13px;border-top:1px solid var(--line);margin-top:40px;padding:22px 0 50px}
|
||||
.jn-err{color:var(--bad)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="jn-nav">
|
||||
<span class="logo-wrap"><a href="/"><img src="/logo.png" alt="InstantAdPay" style="height:34px;display:block"></a><span class="byline">Brought to you by the <b>Crypto Team Build Network</b></span></span>
|
||||
<a class="small muted" href="/my">Already a member? Sign in</a>
|
||||
</div>
|
||||
|
||||
<section class="jn-hero">
|
||||
<div class="jn-spon" id="jnSpon" hidden><img id="jnSponImg" alt="" hidden><span class="small muted">Personal invitation from <b id="jnSponName" style="color:var(--mint)"></b></span></div>
|
||||
<p class="jn-brand">Instant<em>AdPay</em> <span id="jnEyebrow">· Advertise and earn on Polygon</span></p>
|
||||
<h1 id="jnHead">Advertise and earn.<br><em>Paid on-chain, instantly.</em></h1>
|
||||
<p class="lead" id="jnLead">Every ad package splits to real wallets in the same transaction it sells. No pending payouts, no withdraw button, and every payment is public.</p>
|
||||
</section>
|
||||
|
||||
<div class="jn-grid">
|
||||
<div>
|
||||
<div class="mockup" aria-label="worked example of one purchase">
|
||||
<div class="bar"><i></i><i></i><i></i><span class="addr">instantadpay.com/ledger · worked example</span></div>
|
||||
<div class="body">
|
||||
<div class="mrow"><span>🧾 member #7 bought package #2 ($20.00)</span><span>paid</span></div>
|
||||
<div class="mrow"><span>💸 level 1 payout → member #3 (50%)</span><span>same block</span></div>
|
||||
<div class="mrow"><span>💸 level 2 payout → member #2 (20%)</span><span>same block</span></div>
|
||||
<div class="mrow"><span>💸 level 3 payout → member #1 (10%)</span><span>same block</span></div>
|
||||
<div class="mrow dim"><span>🏛 platform fee settled (20%)</span><span>same block</span></div>
|
||||
<div class="mrow dim"><span>⭐ member #3 now has 2 qualifying buyers</span><span>level 2 ✓</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small muted" style="margin:10px 0 0">One purchase, one transaction, four payments. <a href="/ledger" target="_blank" rel="noopener">Open the real ledger →</a> <span id="jnStats"></span></p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card jn-cap" id="jnCap">
|
||||
<h3>Join free</h3>
|
||||
<p class="muted small" style="margin:0 0 12px">Type your email and we send a 6-digit code. No password, no wallet needed today.</p>
|
||||
<p><input id="jnEmail" type="email" placeholder="Your email" autocomplete="email"></p>
|
||||
<p id="jnCodeRow" hidden><input id="jnCode" inputmode="numeric" placeholder="6-digit code from your inbox"></p>
|
||||
<label class="small muted" style="display:flex;gap:8px;align-items:flex-start;margin:0 0 12px;cursor:pointer">
|
||||
<input type="checkbox" id="jnNews" checked style="margin-top:3px;width:auto">
|
||||
<span>Send me the getting-started emails (a few short ones over the first week). Unsubscribe any time.</span>
|
||||
</label>
|
||||
<p id="jnErr" class="small jn-err" hidden></p>
|
||||
<button class="btn" id="jnSend" type="button">Email me a code</button>
|
||||
<button class="btn" id="jnVerify" type="button" hidden>Create my free account</button>
|
||||
<button class="btn sec small" id="jnResend" type="button" hidden style="margin-top:8px">Send a fresh code</button>
|
||||
<p class="small muted" style="margin:14px 0 0">Advertising, not investing. No income is guaranteed; results depend on your effort. Crypto carries risk of loss.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="jn-strip">
|
||||
<div class="jn-chips">
|
||||
<span><b>Paid in POL</b>, Polygon's own coin, to your wallet</span>
|
||||
<span><b>Same transaction</b> payouts</span>
|
||||
<span><b>Public ledger</b> on Polygon</span>
|
||||
<span><b>Free</b> to join by email</span>
|
||||
</div>
|
||||
<p class="small muted" style="margin:10px 0 0">The one crypto here is <b style="color:var(--ink)">POL on the Polygon network</b>: 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.</p>
|
||||
</div>
|
||||
<section class="jn-full">
|
||||
<div class="jn-h"><h2>How it works</h2><p class="muted">Three steps. The first one takes a minute and costs nothing.</p></div>
|
||||
<div class="jn-steps">
|
||||
<div class="card"><div class="n">STEP 1</div><h3>Join free by email</h3><p class="muted small">A 6-digit code, no password. You get an invite link and welcome credits to try real ads.</p></div>
|
||||
<div class="card"><div class="n">STEP 2</div><h3>Advertise or earn</h3><p class="muted small">Seven ad formats. View ads to earn credits, or buy a package from $5 when you want reach.</p></div>
|
||||
<div class="card"><div class="n">STEP 3</div><h3>Get paid in the same transaction</h3><p class="muted small">Anyone who buys through your link pays you 50 percent, on-chain, the moment it happens.</p></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="jn-full">
|
||||
<div class="jn-h"><h2>The packages</h2><p class="muted">Priced in dollars, settled in POL at the live rate. One credit is one cent of ad delivery.</p></div>
|
||||
<div class="jn-ladder" id="jnLadder"></div>
|
||||
<p class="small muted" style="margin:14px 0 0;text-align:center">Every package pays 50 / 20 / 10 up the line the moment it sells, on a public ledger. <a href="#jnCap">Join free</a> and look around first.</p>
|
||||
</section>
|
||||
|
||||
<div class="jn-foot">
|
||||
InstantAdPay · <a href="/contract">Contract</a> · <a href="/terms">Terms</a> · <a href="/privacy">Privacy</a> · <a href="/disclaimer">Disclaimer</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260910c"></script>
|
||||
<script src="/assets/join.js?v=20260910a"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="robots" content="noindex,follow">
|
||||
<title>You're invited | InstantAdPay</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
<style>
|
||||
.jn-nav{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;padding:16px 0}
|
||||
.jn-nav>a{white-space:nowrap;flex:0 0 auto;padding-top:8px}
|
||||
.jn-nav .logo-wrap{min-width:0}
|
||||
@media (max-width:480px){.jn-nav .byline{white-space:normal}}
|
||||
.jn-hero{padding:14px 0 4px;text-align:left}
|
||||
.jn-brand{font-family:var(--disp);font-weight:800;font-size:20px;margin:0 0 4px;color:var(--ink)}
|
||||
.jn-brand em{font-style:normal;color:var(--mint)}
|
||||
.jn-brand span{font-weight:500;font-size:14px;color:var(--muted)}
|
||||
.jn-strip{margin-top:22px}
|
||||
.jn-strip .jn-chips{margin:0}
|
||||
.jn-hero h1{font-size:clamp(26px,3.6vw,40px);line-height:1.08;margin:4px 0 8px;max-width:760px;text-align:left;letter-spacing:-.015em}
|
||||
.jn-hero h1 em{font-style:normal;color:var(--mint)}
|
||||
.jn-hero p.lead{margin:0 0 12px;text-align:left;max-width:620px;font-size:15.5px}
|
||||
.jn-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:22px;align-items:start;margin-top:4px}
|
||||
#jnVideo{width:100%;aspect-ratio:16/9;display:block;border-radius:16px;border:1px solid var(--line-strong);background:#000}
|
||||
/* angle pages (?v=) are squeeze pages: hook, video, one call to action. The full page is for the plain link. */
|
||||
body.squeeze .jn-hero{max-width:720px;margin:0 auto;text-align:center;padding-top:6px}
|
||||
body.squeeze .jn-hero h1,body.squeeze .jn-hero p.lead{text-align:center;margin-left:auto;margin-right:auto}
|
||||
body.squeeze .jn-hero h1{font-size:clamp(30px,4.4vw,46px)}
|
||||
body.squeeze .jn-spon,body.squeeze .jn-brand{justify-content:center}
|
||||
body.squeeze .jn-grid{display:flex;flex-direction:column;max-width:720px;margin:0 auto;gap:18px}
|
||||
body.squeeze .jn-grid>div:first-child>p{display:none}
|
||||
body.squeeze .jn-cap{position:static;box-shadow:0 0 80px rgba(67,232,195,.16)}
|
||||
body.squeeze .jn-cap h3{font-size:22px}
|
||||
body.squeeze #jnMock,body.squeeze .jn-strip,body.squeeze .jn-full{display:none}
|
||||
body.squeeze .jn-points{max-width:720px;margin:4px auto 0}
|
||||
body.squeeze .jn-foot{margin-top:28px}
|
||||
.jn-points{list-style:none;margin:14px 0 0;padding:0;display:flex;flex-direction:column;gap:10px}
|
||||
.jn-points li{position:relative;padding-left:26px;font-size:15px;line-height:1.45}
|
||||
.jn-points li::before{content:"✓";position:absolute;left:0;top:0;color:var(--mint);font-weight:800}
|
||||
@media(max-width:860px){.jn-grid{grid-template-columns:1fr}}
|
||||
.jn-cap{border-color:var(--mint);box-shadow:0 0 60px rgba(67,232,195,.12);position:sticky;top:18px}
|
||||
@media(max-width:860px){.jn-cap{position:static}}
|
||||
.jn-grid>div:first-child .mockup{margin:0}
|
||||
.jn-cap h3{margin:0 0 6px}
|
||||
.jn-cap input{width:100%}
|
||||
.jn-cap .btn{width:100%;text-align:center}
|
||||
.jn-chips{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 18px}
|
||||
.jn-chips span{border:1px solid var(--line-strong);border-radius:999px;padding:6px 14px;font-size:13px;color:var(--muted)}
|
||||
.jn-chips span b{color:var(--mint);font-weight:700}
|
||||
.jn-spon{display:flex;align-items:center;gap:10px;margin:0 0 6px}
|
||||
.jn-spon img{width:34px;height:34px;border-radius:50%;object-fit:cover;border:1px solid var(--line-strong)}
|
||||
.jn-full{margin-top:26px}
|
||||
.jn-h{display:flex;justify-content:space-between;align-items:baseline;gap:16px;flex-wrap:wrap;margin:0 0 14px}
|
||||
.jn-h h2{font-size:26px;margin:0}
|
||||
.jn-h p{margin:0;font-size:14.5px}
|
||||
.jn-steps{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
|
||||
.jn-steps .card h3{font-size:19px;margin:8px 0 8px;min-height:2.4em}
|
||||
.jn-steps .card p{margin:0}
|
||||
@media(max-width:700px){.jn-steps{grid-template-columns:1fr}}
|
||||
.jn-steps .card{margin:0}
|
||||
.jn-steps .n{font-family:var(--mono);color:var(--mint);font-size:12px;letter-spacing:.1em}
|
||||
.jn-ladder{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}
|
||||
@media(max-width:900px){.jn-ladder{grid-template-columns:repeat(3,1fr)}}
|
||||
@media(max-width:560px){.jn-ladder{grid-template-columns:repeat(2,1fr)}}
|
||||
.jn-pk{border:1px solid var(--line);border-radius:12px;padding:14px;text-align:center;background:var(--panel)}
|
||||
.jn-pk .p{font-family:var(--disp);font-size:26px;font-weight:800}
|
||||
.jn-pk .c{color:var(--mint);font-weight:700;font-size:13.5px}
|
||||
.jn-pk .n{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.1em}
|
||||
.jn-foot{color:var(--muted);font-size:13px;border-top:1px solid var(--line);margin-top:40px;padding:22px 0 50px}
|
||||
.jn-err{color:var(--bad)}
|
||||
.jn-spon.cobrand{border:1px solid rgba(67,232,195,.4);border-radius:14px;padding:10px 14px;margin-bottom:12px}.jn-spon.cobrand img{width:56px;height:56px;border-radius:50%}.jn-bio{display:block;margin-top:4px;color:var(--ink);font-size:14px;max-width:60ch}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="jn-nav">
|
||||
<span class="logo-wrap"><a href="/"><img src="/logo.png" alt="InstantAdPay" style="height:34px;display:block"></a><span class="byline">Brought to you by the <b>Crypto Team Build Network</b></span></span>
|
||||
<a class="small muted" href="/my">Already a member? Sign in</a>
|
||||
</div>
|
||||
|
||||
<section class="jn-hero">
|
||||
<div class="jn-spon" id="jnSpon" hidden><img id="jnSponImg" alt="" hidden><span class="small muted">Personal invitation from <b id="jnSponName" style="color:var(--mint)"></b><br><span id="jnSponBio" class="jn-bio" hidden></span></span></div>
|
||||
<p class="jn-brand">Instant<em>AdPay</em> <span id="jnEyebrow">· Advertise and earn on Polygon</span></p>
|
||||
<p class="jn-hello" id="jnHello" hidden style="margin:8px 0 12px;padding:10px 14px;border:1px solid var(--mint);border-radius:12px;color:var(--mint);font-weight:700"></p>
|
||||
<h1 id="jnHead">Advertise and earn.<br><em>Paid on-chain, instantly.</em></h1>
|
||||
<p class="lead" id="jnLead">Every ad package splits to real wallets in the same transaction it sells. No pending payouts, no withdraw button, and every payment is public.</p>
|
||||
</section>
|
||||
|
||||
<div class="jn-grid">
|
||||
<div>
|
||||
<div id="jnAngle" hidden>
|
||||
<video id="jnVideo" controls playsinline preload="metadata"></video>
|
||||
</div>
|
||||
<div id="jnMock">
|
||||
<div class="mockup" aria-label="worked example of one purchase">
|
||||
<div class="bar"><i></i><i></i><i></i><span class="addr">instantadpay.com/ledger · worked example</span></div>
|
||||
<div class="body">
|
||||
<div class="mrow"><span>🧾 member #7 bought package #2 ($20.00)</span><span>paid</span></div>
|
||||
<div class="mrow"><span>💸 level 1 payout → member #3 (50%)</span><span>same block</span></div>
|
||||
<div class="mrow"><span>💸 level 2 payout → member #2 (20%)</span><span>same block</span></div>
|
||||
<div class="mrow"><span>💸 level 3 payout → member #1 (10%)</span><span>same block</span></div>
|
||||
<div class="mrow dim"><span>🏛 platform fee settled (20%)</span><span>same block</span></div>
|
||||
<div class="mrow dim"><span>⭐ member #3 now has 2 qualifying buyers</span><span>level 2 ✓</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small muted" style="margin:10px 0 0">One purchase, one transaction, four payments. <a href="/ledger" target="_blank" rel="noopener">Open the real ledger →</a> <span id="jnStats"></span></p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card jn-cap" id="jnCap">
|
||||
<h3 id="jnCapH">Join free</h3>
|
||||
<p class="muted small" style="margin:0 0 12px" id="jnCapSub">Type your email and we send a 6-digit code. No password, no wallet needed today.</p>
|
||||
<p><input id="jnEmail" type="email" placeholder="Your email" autocomplete="email"></p>
|
||||
<input id="jnWebsite" class="hp-field" type="text" name="hp_field_x9" tabindex="-1" autocomplete="off" aria-hidden="true">
|
||||
<div id="jnCheck" hidden></div>
|
||||
<p id="jnCodeRow" hidden><input id="jnCode" inputmode="numeric" placeholder="6-digit code from your inbox"></p>
|
||||
<p id="jnUnder" class="small muted" hidden style="margin:-4px 0 12px"></p>
|
||||
<label class="small muted" style="display:flex;gap:8px;align-items:flex-start;margin:0 0 12px;cursor:pointer">
|
||||
<input type="checkbox" id="jnNews" checked style="margin-top:3px;width:auto">
|
||||
<span>Send me the getting-started emails (a few short ones over the first week). Unsubscribe any time.</span>
|
||||
</label>
|
||||
<p id="jnErr" class="small jn-err" hidden></p>
|
||||
<button class="btn" id="jnSend" type="button">Email me a code</button>
|
||||
<button class="btn" id="jnVerify" type="button" hidden>Create my free account</button>
|
||||
<button class="btn sec small" id="jnResend" type="button" hidden style="margin-top:8px">Send a fresh code</button>
|
||||
<p class="small muted" style="margin:14px 0 0">Advertising, not investing. No income is guaranteed; results depend on your effort. Crypto carries risk of loss.</p>
|
||||
</div>
|
||||
<ul class="jn-points" id="jnPoints" hidden></ul>
|
||||
</div>
|
||||
|
||||
<div class="jn-strip">
|
||||
<div class="jn-chips">
|
||||
<span><b>Paid in POL</b>, Polygon's own coin, to your wallet</span>
|
||||
<span><b>Same transaction</b> payouts</span>
|
||||
<span><b>Public ledger</b> on Polygon</span>
|
||||
<span><b>Free</b> to join by email</span>
|
||||
</div>
|
||||
<p class="small muted" style="margin:10px 0 0">The one crypto here is <b style="color:var(--ink)">POL on the Polygon network</b>: 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.</p>
|
||||
</div>
|
||||
<section class="jn-full">
|
||||
<div class="jn-h"><h2>How it works</h2><p class="muted">Three steps. The first one takes a minute and costs nothing.</p></div>
|
||||
<div class="jn-steps">
|
||||
<div class="card"><div class="n">STEP 1</div><h3>Join free by email</h3><p class="muted small">A 6-digit code, no password. You get an invite link and welcome credits to try real ads.</p></div>
|
||||
<div class="card"><div class="n">STEP 2</div><h3>Advertise or earn</h3><p class="muted small">Seven ad formats. View ads to earn credits, or buy a package from $5 when you want reach.</p></div>
|
||||
<div class="card"><div class="n">STEP 3</div><h3>Get paid in the same transaction</h3><p class="muted small">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.</p></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="jn-full">
|
||||
<div class="jn-h"><h2>The packages</h2><p class="muted">Priced in dollars, settled in POL at the live rate. One credit is one cent of ad delivery.</p></div>
|
||||
<div class="jn-ladder" id="jnLadder"></div>
|
||||
<p class="small muted" style="margin:14px 0 0;text-align:center">Every package pays 50 / 20 / 10 up the line the moment it sells, on a public ledger. <a href="#jnCap">Join free</a> and look around first.</p>
|
||||
</section>
|
||||
|
||||
<div class="jn-foot">
|
||||
InstantAdPay · <a href="/contract">Contract</a> · <a href="/terms">Terms</a> · <a href="/privacy">Privacy</a> · <a href="/disclaimer">Disclaimer</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260923b"></script><script src="/assets/welcome.js?v=20260923b"></script>
|
||||
<script src="/assets/join.js?v=20260914a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Founding week checklist | InstantAdPay</title>
|
||||
<meta name="description" content="The eight things a leader gets done before launch, and why the order matters. Member training.">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260911c">
|
||||
<style>
|
||||
.lw-rule{border-left:4px solid #f2c94c;background:rgba(242,201,76,.08);padding:16px 20px;border-radius:0 12px 12px 0;margin:0 0 24px}
|
||||
.lw-rule b{font-family:var(--disp);font-size:17px;display:block;margin-bottom:6px}
|
||||
.lw-top{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;margin:0 0 18px}
|
||||
.lw-count{font-family:var(--disp);font-weight:800;font-size:15px}
|
||||
.lw-count b{color:var(--mint);font-size:22px}
|
||||
.lw-bar{height:10px;border-radius:99px;background:rgba(255,255,255,.06);overflow:hidden;margin:8px 0 0;width:100%}
|
||||
.lw-bar i{display:block;height:100%;background:linear-gradient(90deg,var(--mint),#9ff2dc);width:0;transition:width .4s}
|
||||
.lw-when{font-family:var(--mono);font-size:12px;color:var(--muted)}
|
||||
.lw-when b{color:var(--ink);font-family:var(--disp);font-size:18px;display:block}
|
||||
.chk{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
|
||||
.chk li{display:grid;grid-template-columns:34px 1fr auto;gap:14px;align-items:start;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
|
||||
.chk li.done{border-color:rgba(67,232,195,.45)}
|
||||
.chk .box{width:30px;height:30px;border-radius:9px;border:2px solid var(--line-strong);display:flex;align-items:center;justify-content:center;font-weight:800;color:var(--mint);font-size:18px}
|
||||
.chk li.done .box{background:rgba(67,232,195,.14);border-color:var(--mint)}
|
||||
.chk h3{margin:0 0 3px;font-size:16px}
|
||||
.chk p{margin:0;color:var(--muted);font-size:14px}
|
||||
.chk .why{margin-top:6px;font-size:13px;color:var(--ink)}
|
||||
.chk .act{white-space:nowrap}
|
||||
.chk .act .btn{margin-top:0}
|
||||
.days{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
|
||||
.days>div{background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:14px}
|
||||
.days .k{font-family:var(--disp);font-weight:800;color:var(--mint);margin-bottom:6px}
|
||||
.days ul{margin:0;padding-left:18px}
|
||||
blockquote{margin:0 0 10px;background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:10px;padding:12px 16px;color:var(--muted)}
|
||||
blockquote span{display:block;font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mint);margin-bottom:6px}
|
||||
.swipe{margin:0 0 12px;background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:10px;padding:12px 16px}
|
||||
.swipe .cap{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mint);margin-bottom:6px;display:flex;justify-content:space-between;gap:10px;align-items:center}
|
||||
.swipe .subj{font-weight:700;margin:0 0 8px}
|
||||
.swipe pre{white-space:pre-wrap;font:inherit;color:var(--muted);margin:0;line-height:1.5}
|
||||
.swipe .btn{font-size:12px;padding:5px 10px}
|
||||
.lg-imgs{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:0 0 14px} .lg-imgs figure{margin:0} .lg-imgs img{width:100%;height:auto;border-radius:10px;border:1px solid var(--line)} .lg-imgs figcaption{font-size:12px;color:var(--muted);margin-top:6px} .lg-imgs a{color:var(--mint)}
|
||||
@media (max-width:640px){.lg-imgs{grid-template-columns:1fr}}
|
||||
h2{margin:34px 0 10px;font-size:26px}
|
||||
#gate{display:none}
|
||||
@media (max-width:720px){.days{grid-template-columns:1fr 1fr}.chk li{grid-template-columns:34px 1fr}.chk .act{grid-column:2}}
|
||||
@media print{body{background:#fff;color:#000}.jn-nav,nav,footer,.act,#printBtn{display:none!important}.chk li{border-color:#999}.chk p,.chk .why{color:#000}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero" style="padding:70px 0 20px">
|
||||
<p class="eyebrow">Leaders · founding week</p>
|
||||
<h1>Eight things before <em>launch day</em>.</h1>
|
||||
<p class="lead">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.</p>
|
||||
</section>
|
||||
|
||||
<div class="card" id="gate"><h3>Members only</h3><p class="muted small">Sign in to your member area to open the checklist. <a href="/my">Sign in</a></p></div>
|
||||
|
||||
<div id="body">
|
||||
<div class="lw-top">
|
||||
<div style="flex:1 1 300px"><div class="lw-count"><b id="lwDone">0</b> of 8 ready</div><div class="lw-bar"><i id="lwBar"></i></div></div>
|
||||
<div class="lw-when" id="lwWhen" hidden>Launch<b id="lwWhenAt"></b><span id="lwWhenIn"></span></div>
|
||||
<button class="btn small sec" id="printBtn" type="button">Print</button>
|
||||
</div>
|
||||
|
||||
<div class="lw-rule">
|
||||
<b>The one rule that makes this week matter: unqualified levels pass up.</b>
|
||||
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.
|
||||
</div>
|
||||
|
||||
<ol class="chk" id="chk"></ol>
|
||||
|
||||
<h2>The week, day by day</h2>
|
||||
<div class="days">
|
||||
<div><div class="k">Day 1</div><ul><li>Items 1 to 3 done in one sitting.</li><li>Decide your play, and whether you are going for level 2 or all three.</li></ul></div>
|
||||
<div><div class="k">Days 2 to 3</div><ul><li>Qualify: two real buyers, or Qualified Start. Leaders: go to five and open level 3.</li><li>Line banner uploaded.</li></ul></div>
|
||||
<div><div class="k">Days 4 to 6</div><ul><li>Place your first two personally.</li><li>Walk them through items 1 to 3 on their accounts.</li></ul></div>
|
||||
<div><div class="k">Launch day</div><ul><li>Everyone releases links at the same time.</li><li>Watch the ledger and the Telegram proof feed fill.</li></ul></div>
|
||||
</div>
|
||||
|
||||
<h2>What to send your two this week</h2>
|
||||
<blockquote><span>Text or DM · before launch</span>I'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: <span data-link>your link</span></blockquote>
|
||||
<blockquote><span>Text or DM · after they join</span>Three 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.</blockquote>
|
||||
|
||||
<h2 id="share">Launch graphics and posts</h2>
|
||||
<p class="small muted" style="margin:0 0 10px">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.</p>
|
||||
<div class="lg-imgs">
|
||||
<figure><img src="/banners/iap-launch-doors-1200x630.jpg" alt="Doors open Monday" loading="lazy"><figcaption>Wide, 1200x630 · for X, Facebook, LinkedIn, link previews <a href="/banners/iap-launch-doors-1200x630.jpg" download>Download</a></figcaption></figure>
|
||||
<figure><img src="/banners/iap-launch-doors-1080x1080.jpg" alt="Doors open Monday" loading="lazy"><figcaption>Square, 1080x1080 · for Instagram, Telegram, WhatsApp <a href="/banners/iap-launch-doors-1080x1080.jpg" download>Download</a></figcaption></figure>
|
||||
</div>
|
||||
<div id="postList"></div>
|
||||
|
||||
<h2 id="swipes">Launch week swipes: four emails for your list</h2>
|
||||
<p class="small muted" style="margin:0 0 10px">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.</p>
|
||||
<div id="swipeList"></div>
|
||||
|
||||
<p class="small muted" style="margin:26px 0 0">No income is guaranteed. Results depend on your effort. Crypto carries risk of loss. InstantAdPay sells advertising; it is not an investment.</p>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div>InstantAdPay · <a href="/my#training">back to Training</a> · <a href="/plays">team-building plays</a> · <a href="/wallets">wallets and buying POL</a></div>
|
||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees.</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260923b"></script><script src="/assets/welcome.js?v=20260923b"></script>
|
||||
<script src="/assets/launch.js?v=20260915b"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,44 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Live ledger | InstantAdPay</title>
|
||||
<meta name="description" content="Every purchase, payout, and pass-up on InstantAdPay, streamed straight from the blockchain with a verify link on every line.">
|
||||
<link rel="canonical" href="https://instantadpay.com/ledger">
|
||||
<meta property="og:type" content="website"><meta property="og:site_name" content="InstantAdPay">
|
||||
<meta property="og:url" content="https://instantadpay.com/ledger">
|
||||
<meta property="og:title" content="InstantAdPay — the live payout ledger">
|
||||
<meta property="og:description" content="Watch real ad purchases split into instant on-chain payouts, live. Every line links to the raw transaction.">
|
||||
<meta property="og:image" content="https://instantadpay.com/banners/iap-hero-1200x630.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="InstantAdPay — the live payout ledger">
|
||||
<meta name="twitter:description" content="Real ad purchases splitting into instant on-chain payouts, live and verifiable.">
|
||||
<meta name="twitter:image" content="https://instantadpay.com/banners/iap-hero-1200x630.png">
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero" style="padding-bottom:20px">
|
||||
<h1>The ledger <em>does not lie</em>.</h1>
|
||||
<p class="lead">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.</p>
|
||||
<p><span class="badge" id="liveBadge">connecting…</span>
|
||||
<span class="small muted" id="statLine"></span></p>
|
||||
</section>
|
||||
<div class="card" id="adSlotBanner" hidden></div>
|
||||
<div class="card" style="padding:0">
|
||||
<div class="feed" id="feed"><div class="row muted">Loading recent history…</div></div>
|
||||
</div>
|
||||
<div class="card small" id="adSlotText" hidden></div>
|
||||
<footer>
|
||||
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260910c"></script>
|
||||
<script src="/assets/ledger.js?v=20260906m"></script>
|
||||
<script src="/assets/chat.js?v=20260906m"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Live ledger | InstantAdPay</title>
|
||||
<meta name="description" content="Every purchase, payout, and pass-up on InstantAdPay, streamed straight from the blockchain with a verify link on every line.">
|
||||
<link rel="canonical" href="https://instantadpay.com/ledger">
|
||||
<meta property="og:type" content="website"><meta property="og:site_name" content="InstantAdPay">
|
||||
<meta property="og:url" content="https://instantadpay.com/ledger">
|
||||
<meta property="og:title" content="InstantAdPay — the live payout ledger">
|
||||
<meta property="og:description" content="Watch real ad purchases split into instant on-chain payouts, live. Every line links to the raw transaction.">
|
||||
<meta property="og:image" content="https://instantadpay.com/banners/iap-hero-1200x630.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="InstantAdPay — the live payout ledger">
|
||||
<meta name="twitter:description" content="Real ad purchases splitting into instant on-chain payouts, live and verifiable.">
|
||||
<meta name="twitter:image" content="https://instantadpay.com/banners/iap-hero-1200x630.png">
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero" style="padding-bottom:20px">
|
||||
<h1>The ledger <em>does not lie</em>.</h1>
|
||||
<p class="lead">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.</p>
|
||||
<p><span class="badge" id="liveBadge">connecting…</span>
|
||||
<span class="small muted" id="statLine"></span></p>
|
||||
</section>
|
||||
<div class="card" id="adSlotBanner" hidden></div>
|
||||
<div class="card" style="padding:0">
|
||||
<div data-ph-slot="0" style="margin:0 0 10px"></div>
|
||||
<div class="feed" id="feed"><div class="row muted">Loading recent history…</div></div>
|
||||
<div class="nas-zone" style="max-width:728px;margin:22px auto 0;overflow:hidden;text-align:center"><!-- nas-ledger --><script src="https://www.networkadspace.com/showadss.php?w=728&h=90&n=1&bw=728&bh=90&c=999"></script></div>
|
||||
</div>
|
||||
<div class="card small" id="adSlotText" hidden></div>
|
||||
<footer>
|
||||
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260923b"></script><script src="/assets/welcome.js?v=20260923b"></script>
|
||||
<script src="/assets/ledger.js?v=20260906m"></script>
|
||||
<script src="https://polhunter.com/embed.js" data-slots="1" async></script>
|
||||
<script src="/assets/chat.js?v=20260906m"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 105 KiB |