LinkSpin test area: InstantAdPay engine fork rebranded, network registry, sponsor carry-over with engine activation and claim window, rotator with /r/ redirects, link-domain mini-sites
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
data/
|
||||
*.log
|
||||
qa/out/
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache ffmpeg ttf-dejavu
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
COPY . .
|
||||
RUN mkdir -p /app/data
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
CMD ["node","server.js"]
|
||||
@@ -0,0 +1,32 @@
|
||||
# LinkSpin — site
|
||||
|
||||
Membership advertising with immutable on-chain settlement. Zero-dependency
|
||||
Node server (RM Circle pattern): static pages + JSON API + SSE ledger.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `server.js` — http server: pages, `/api/*`, `/join/<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, last-touch sponsor
|
||||
attribution, handles). The CHAIN is the source of truth for money/credits.
|
||||
- `public/` — landing, live ledger, member area; no client libraries
|
||||
|
||||
## Chain flip (rehearsal → mainnet)
|
||||
|
||||
Everything chain-specific lives in `data/config.json` (volume):
|
||||
`{contract, chainId, chainName, explorer, rpcs, deployBlock}`.
|
||||
Defaults point at the **Amoy rehearsal** deployment. Launch = deploy the
|
||||
mainnet contract, wipe `accounts.json`/`sessions.json`/`chain-index.json`,
|
||||
PATCH `/api/admin/chain` with the mainnet values, set `rehearsal:false` via
|
||||
`/api/admin/site`. Same code, different config.
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
PORT=3100 node server.js # DATA_DIR defaults to ./data
|
||||
```
|
||||
|
||||
Admin API auth: `Authorization: Bearer $ADMIN_PASSWORD`.
|
||||
|
||||
Spec: `../CONTRACT-SPEC.md` (v1.0.3-frozen). Contracts: `../contracts/`.
|
||||
@@ -0,0 +1,491 @@
|
||||
// Site-side member accounts. Dual-mode:
|
||||
// MySQL (db.enabled) for real concurrency in production,
|
||||
// JSON volume file as the no-DATABASE_URL fallback (local dev).
|
||||
// All exported functions are async; both modes return identical shapes.
|
||||
// The chain remains the source of truth for money/credits/qualification.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const db = require('./db');
|
||||
|
||||
let DATA_DIR = null;
|
||||
|
||||
// ---- shared helpers ----
|
||||
function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16);
|
||||
const hash = crypto.scryptSync(String(password), salt, 32);
|
||||
return salt.toString('hex') + ':' + hash.toString('hex');
|
||||
}
|
||||
function checkPassword(password, stored) {
|
||||
try {
|
||||
const [saltHex, hashHex] = String(stored).split(':');
|
||||
const hash = crypto.scryptSync(String(password), Buffer.from(saltHex, 'hex'), 32);
|
||||
return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex'));
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||
const normEmail = e => String(e || '').trim().toLowerCase();
|
||||
const normAddr = a => String(a || '').trim().toLowerCase();
|
||||
function newCode(taken) {
|
||||
let c;
|
||||
do { c = crypto.randomBytes(5).toString('base64url').replace(/[-_]/g, '').slice(0, 7).toLowerCase(); }
|
||||
while (!c || c.length < 6 || /^\d+$/.test(c) || (taken && taken(c)));
|
||||
return c;
|
||||
}
|
||||
const pub = a => a ? { email: a.email, sponsorRef: a.sponsorRef || '', code: a.code || null,
|
||||
username: a.username || null, memberId: a.memberId || 0, joinedVia: a.joinedVia || null, joinedRef: a.joinedRef || null,
|
||||
lineBannerUrl: a.lineBannerUrl || null, lineTargetUrl: a.lineTargetUrl || null, wallOffers: a.wallOffers || null,
|
||||
avatarUrl: a.avatarUrl || null, bio: a.bio || null, socials: a.socials || null,
|
||||
chatAvailable: a.chatAvailable === false ? false : true, lastSeen: a.lastSeen || 0,
|
||||
address: a.address || null, created: a.created } : null;
|
||||
const USER_RE = /^[a-zA-Z0-9_]{3,20}$/;
|
||||
const normUser = u => String(u || '').trim().toLowerCase();
|
||||
|
||||
// ---- JSON fallback ----
|
||||
const J = {
|
||||
db: { v: 2, byEmail: {}, byAddress: {}, byCode: {}, joins: 0 },
|
||||
FILE: () => path.join(DATA_DIR, 'accounts.json'),
|
||||
load() {
|
||||
try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) {}
|
||||
if (!this.db || !this.db.v) this.db = { v: 2, byEmail: {}, byAddress: {}, byCode: {}, joins: 0 };
|
||||
if (!this.db.byCode) this.db.byCode = {};
|
||||
if (!this.db.positions) this.db.positions = {};
|
||||
for (const a of Object.values(this.db.byEmail)) {
|
||||
if (!a.code) { a.code = newCode(c => this.db.byCode[c]); this.db.byCode[a.code] = a.email; }
|
||||
else if (!this.db.byCode[a.code]) this.db.byCode[a.code] = a.email;
|
||||
}
|
||||
},
|
||||
save() {
|
||||
try {
|
||||
const tmp = this.FILE() + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(this.db), { mode: 0o600 });
|
||||
fs.renameSync(tmp, this.FILE());
|
||||
} catch (e) { console.error('accounts save failed', e.message); }
|
||||
},
|
||||
async signup(e, password, ref) {
|
||||
if (this.db.byEmail[e]) return { error: 'That email already has an account. Log in instead.' };
|
||||
const code = newCode(c => this.db.byCode[c]);
|
||||
this.db.byEmail[e] = { email: e, pass: hashPassword(password), sponsorRef: ref, code, address: null, created: Date.now() };
|
||||
this.db.byCode[code] = e;
|
||||
this.save();
|
||||
return { ok: true, created: true, account: pub(this.db.byEmail[e]) };
|
||||
},
|
||||
async login(e, password) {
|
||||
const a = this.db.byEmail[e];
|
||||
if (!a || !a.pass || !checkPassword(password, a.pass)) return { error: 'Wrong email or password.' };
|
||||
return { ok: true, account: pub(a) };
|
||||
},
|
||||
async ensure(e, ref, via, joinedRef) {
|
||||
let created = false;
|
||||
if (!this.db.byEmail[e]) {
|
||||
const code = newCode(c => this.db.byCode[c]);
|
||||
this.db.byEmail[e] = { email: e, pass: null, sponsorRef: ref, code, address: null, created: Date.now(), joinedVia: via || null, joinedRef: joinedRef || null };
|
||||
this.db.byCode[code] = e;
|
||||
created = true;
|
||||
this.save();
|
||||
}
|
||||
return { ok: true, created, account: pub(this.db.byEmail[e]) };
|
||||
},
|
||||
async byEmail(e) { return pub(this.db.byEmail[e]); },
|
||||
async byAddress(a) { const e = this.db.byAddress[a]; return e ? pub(this.db.byEmail[e]) : null; },
|
||||
async byCode(c) { const e = this.db.byCode[c]; return e ? pub(this.db.byEmail[e]) : null; },
|
||||
async byUsername(u) {
|
||||
for (const a of Object.values(this.db.byEmail)) if (a.username === u) return pub(a);
|
||||
return null;
|
||||
},
|
||||
async setUsername(e, u) {
|
||||
const acct = this.db.byEmail[e];
|
||||
if (!acct) return { error: 'No such account.' };
|
||||
for (const a of Object.values(this.db.byEmail)) if (a.username === u && a.email !== e)
|
||||
return { error: 'That username is taken. Try another.' };
|
||||
acct.username = u;
|
||||
this.save();
|
||||
return { ok: true, account: pub(acct) };
|
||||
},
|
||||
async setLineBanner(e, bannerUrl, targetUrl) {
|
||||
const acct = this.db.byEmail[e];
|
||||
if (!acct) return { error: 'No such account.' };
|
||||
acct.lineBannerUrl = bannerUrl || null;
|
||||
acct.lineTargetUrl = targetUrl || null;
|
||||
this.save();
|
||||
return { ok: true, account: pub(acct) };
|
||||
},
|
||||
async setWallOffers(e, json) {
|
||||
const acct = this.db.byEmail[e];
|
||||
if (!acct) return { error: 'No such account.' };
|
||||
acct.wallOffers = json || null; this.save();
|
||||
return { ok: true, account: pub(acct) };
|
||||
},
|
||||
async setProfile(e, avatarUrl, bio, socials) {
|
||||
const acct = this.db.byEmail[e];
|
||||
if (!acct) return { error: 'No such account.' };
|
||||
if (avatarUrl !== undefined) acct.avatarUrl = avatarUrl || null;
|
||||
if (bio !== undefined) acct.bio = bio || null;
|
||||
if (socials !== undefined) acct.socials = socials || null;
|
||||
this.save();
|
||||
return { ok: true, account: pub(acct) };
|
||||
},
|
||||
async touchSeen(e) { const a = this.db.byEmail[e]; if (a) { a.lastSeen = Date.now(); this.save(); } },
|
||||
async setChatAvailable(e, v) { const a = this.db.byEmail[e]; if (!a) return { error: 'No such account.' }; a.chatAvailable = !!v; this.save(); return { ok: true, available: !!v }; },
|
||||
async getMutes(e) { const a = this.db.byEmail[e]; return (a && Array.isArray(a.chatMutes)) ? a.chatMutes : []; },
|
||||
async setMute(owner, target, muted) {
|
||||
const a = this.db.byEmail[owner]; if (!a) return { error: 'No such account.' };
|
||||
const set = new Set((a.chatMutes || []).map(x => String(x).toLowerCase())); const t = String(target).toLowerCase();
|
||||
if (muted) set.add(t); else set.delete(t);
|
||||
a.chatMutes = [...set].slice(0, 500); this.save(); return { ok: true, muted: !!muted };
|
||||
},
|
||||
async byMemberId(id) {
|
||||
const a = Object.values(this.db.byEmail).find(x => x.memberId === Number(id));
|
||||
return a ? pub(a) : null;
|
||||
},
|
||||
async setMemberId(e, id) {
|
||||
const acct = this.db.byEmail[e];
|
||||
if (acct && acct.memberId !== id) { acct.memberId = id; this.save(); }
|
||||
},
|
||||
async namesForMembers(ids) {
|
||||
const out = {};
|
||||
for (const a of Object.values(this.db.byEmail))
|
||||
if (a.username && a.memberId && ids.includes(a.memberId)) out[a.memberId] = a.username;
|
||||
return out;
|
||||
},
|
||||
async listByReferrer(refs) {
|
||||
const set = new Set(refs.filter(Boolean).map(String));
|
||||
return Object.values(this.db.byEmail)
|
||||
.filter(a => set.has(String(a.sponsorRef || '')))
|
||||
.sort((a, b) => b.created - a.created)
|
||||
.slice(0, 200).map(pub);
|
||||
},
|
||||
async linkWallet(e, a) {
|
||||
const acct = this.db.byEmail[e];
|
||||
if (!acct) return { error: 'No such account.' };
|
||||
if (acct.address && acct.address !== a) return { error: 'This account is already linked to wallet '
|
||||
+ acct.address.slice(0, 6) + '…' + acct.address.slice(-4) + '. Connect that wallet instead.' };
|
||||
if (this.db.byAddress[a] && this.db.byAddress[a] !== e) return { error: 'That wallet is already linked to a different account.' };
|
||||
if (this.db.positions[a]) return { error: 'That wallet is already a linked position' + (this.db.positions[a].email === e ? ' on this account.' : ' on a different account.') };
|
||||
acct.address = a;
|
||||
this.db.byAddress[a] = e;
|
||||
this.save();
|
||||
return { ok: true, account: pub(acct) };
|
||||
},
|
||||
async listAll(limit) { return Object.values(this.db.byEmail).sort((a, b) => (b.created || 0) - (a.created || 0)).slice(0, limit).map(pub); },
|
||||
async setSponsorRef(e, ref) {
|
||||
const acct = this.db.byEmail[e];
|
||||
if (!acct) return { error: 'No such account.' };
|
||||
acct.sponsorRef = ref; this.save();
|
||||
return { ok: true, account: pub(acct) };
|
||||
},
|
||||
async count() { return Object.keys(this.db.byEmail).length; },
|
||||
// ---- linked positions (extra wallets on one account) ----
|
||||
async positions(e) {
|
||||
return Object.entries(this.db.positions).filter(([, p]) => p.email === e)
|
||||
.map(([address, p]) => ({ address, email: p.email, memberId: p.memberId || 0, created: p.created }))
|
||||
.sort((x, y) => x.created - y.created);
|
||||
},
|
||||
async addPosition(e, a) {
|
||||
const acct = this.db.byEmail[e];
|
||||
if (!acct) return { error: 'No such account.' };
|
||||
if (!acct.address) return { error: 'Link your main wallet first.' };
|
||||
if (acct.address === a) return { error: 'That is your main wallet. Switch to a different account in your wallet app, then try again.' };
|
||||
if (this.db.byAddress[a]) return { error: 'That wallet is already the main wallet of another account.' };
|
||||
const cur = this.db.positions[a];
|
||||
if (cur && cur.email !== e) return { error: 'That wallet is already a position on a different account.' };
|
||||
if (!cur) { this.db.positions[a] = { email: e, memberId: 0, created: Date.now() }; this.save(); }
|
||||
return { ok: true, address: a, created: !cur };
|
||||
},
|
||||
async setPositionMember(a, id) { const p = this.db.positions[a]; if (p && p.memberId !== id) { p.memberId = id; this.save(); } },
|
||||
async positionOwner(a) { const p = this.db.positions[a]; return p ? { address: a, email: p.email, memberId: p.memberId || 0 } : null; },
|
||||
async positionByMember(id) { const e = Object.entries(this.db.positions).find(([, p]) => p.memberId === Number(id)); return e ? { address: e[0], email: e[1].email, memberId: e[1].memberId } : null; },
|
||||
async removePosition(e, a) {
|
||||
const p = this.db.positions[a];
|
||||
if (!p || p.email !== e) return { error: 'No such position.' };
|
||||
if (p.memberId) return { error: 'That position is already registered on-chain and cannot be unlinked.' };
|
||||
delete this.db.positions[a]; this.save(); return { ok: true };
|
||||
},
|
||||
// admin only: point the account at a different main wallet (or none); the caller re-reads the member id
|
||||
async adminSetAddress(e, a) {
|
||||
const acct = this.db.byEmail[e]; if (!acct) return { error: 'No such account.' };
|
||||
if (a) {
|
||||
if (this.db.byAddress[a] && this.db.byAddress[a] !== e) return { error: 'That wallet is the main wallet of another account.' };
|
||||
if (this.db.positions[a]) return { error: 'That wallet is a linked position on ' + (this.db.positions[a].email === e ? 'this' : 'another') + ' account.' };
|
||||
}
|
||||
if (acct.address) delete this.db.byAddress[acct.address];
|
||||
acct.address = a || null; if (a) this.db.byAddress[a] = e; this.save(); return { ok: true, account: pub(acct) };
|
||||
},
|
||||
async positionByAddress(a) { const p = this.db.positions[a]; return p ? { address: a, email: p.email, memberId: p.memberId } : null; },
|
||||
async removeAccount(e) {
|
||||
const acct = this.db.byEmail[e]; if (!acct) return { error: 'No such account.' };
|
||||
if (acct.address) delete this.db.byAddress[acct.address]; if (acct.code) delete this.db.byCode[acct.code];
|
||||
for (const [a, p] of Object.entries(this.db.positions)) if (p.email === e) delete this.db.positions[a];
|
||||
delete this.db.byEmail[e]; this.save(); return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
// ---- MySQL mode ----
|
||||
const rowPub = r => r ? pub({ email: r.email, sponsorRef: r.sponsor_ref, code: r.code,
|
||||
username: r.username, memberId: r.member_id || 0, joinedVia: r.joined_via || null, joinedRef: r.joined_ref || null,
|
||||
lineBannerUrl: r.line_banner_url, lineTargetUrl: r.line_target_url, wallOffers: r.wall_offers || null,
|
||||
avatarUrl: r.avatar_url, bio: r.bio, socials: r.socials,
|
||||
chatAvailable: r.chat_available === 0 ? false : true, lastSeen: Number(r.last_seen || 0),
|
||||
address: r.address, created: Number(r.created) }) : null;
|
||||
const D = {
|
||||
async signup(e, password, ref) {
|
||||
const code = newCode();
|
||||
try {
|
||||
await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created) VALUES (?,?,?,?,NULL,?)',
|
||||
[e, hashPassword(password), ref, code, Date.now()]);
|
||||
} catch (err) {
|
||||
if (err.code === 'ER_DUP_ENTRY') return String(err.message).includes('code')
|
||||
? this.signup(e, password, ref) // code collision: retry with a new code
|
||||
: { error: 'That email already has an account. Log in instead.' };
|
||||
throw err;
|
||||
}
|
||||
return { ok: true, created: true, account: await this.byEmail(e) };
|
||||
},
|
||||
async login(e, password) {
|
||||
const rows = await db.q('SELECT * FROM accounts WHERE email=?', [e]);
|
||||
if (!rows.length || !rows[0].pass || !checkPassword(password, rows[0].pass)) return { error: 'Wrong email or password.' };
|
||||
return { ok: true, account: rowPub(rows[0]) };
|
||||
},
|
||||
async ensure(e, ref, via, joinedRef) {
|
||||
const code = newCode();
|
||||
let created = false;
|
||||
try {
|
||||
await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created,joined_via,joined_ref) VALUES (?,NULL,?,?,NULL,?,?,?)',
|
||||
[e, ref, code, Date.now(), via || null, joinedRef || null]);
|
||||
created = true;
|
||||
} catch (err) {
|
||||
if (err.code !== 'ER_DUP_ENTRY') throw err;
|
||||
if (String(err.message).includes('code')) return this.ensure(e, ref);
|
||||
}
|
||||
return { ok: true, created, account: await this.byEmail(e) };
|
||||
},
|
||||
async byEmail(e) { const r = await db.q('SELECT * FROM accounts WHERE email=?', [e]); return rowPub(r[0]); },
|
||||
async byAddress(a) { const r = await db.q('SELECT * FROM accounts WHERE address=?', [a]); return rowPub(r[0]); },
|
||||
async byCode(c) { const r = await db.q('SELECT * FROM accounts WHERE code=?', [c]); return rowPub(r[0]); },
|
||||
async byUsername(u) { const r = await db.q('SELECT * FROM accounts WHERE username=?', [u]); return rowPub(r[0]); },
|
||||
async setUsername(e, u) {
|
||||
try { await db.q('UPDATE accounts SET username=? WHERE email=?', [u, e]); }
|
||||
catch (err) {
|
||||
if (err.code === 'ER_DUP_ENTRY') return { error: 'That username is taken. Try another.' };
|
||||
throw err;
|
||||
}
|
||||
return { ok: true, account: await this.byEmail(e) };
|
||||
},
|
||||
async setLineBanner(e, bannerUrl, targetUrl) {
|
||||
await db.q('UPDATE accounts SET line_banner_url=?, line_target_url=? WHERE email=?', [bannerUrl || null, targetUrl || null, e]);
|
||||
return { ok: true, account: await this.byEmail(e) };
|
||||
},
|
||||
async setWallOffers(e, json) {
|
||||
await db.q('UPDATE accounts SET wall_offers=? WHERE email=?', [json || null, e]);
|
||||
return { ok: true, account: await this.byEmail(e) };
|
||||
},
|
||||
async setProfile(e, avatarUrl, bio, socials) {
|
||||
if (avatarUrl !== undefined) await db.q('UPDATE accounts SET avatar_url=? WHERE email=?', [avatarUrl || null, e]);
|
||||
if (bio !== undefined) await db.q('UPDATE accounts SET bio=? WHERE email=?', [bio || null, e]);
|
||||
if (socials !== undefined) await db.q('UPDATE accounts SET socials=? WHERE email=?', [socials || null, e]);
|
||||
return { ok: true, account: await this.byEmail(e) };
|
||||
},
|
||||
async touchSeen(e) { await db.q('UPDATE accounts SET last_seen=? WHERE email=?', [Date.now(), e]); },
|
||||
async setChatAvailable(e, v) { await db.q('UPDATE accounts SET chat_available=? WHERE email=?', [v ? 1 : 0, e]); return { ok: true, available: !!v }; },
|
||||
async getMutes(e) {
|
||||
const r = await db.q('SELECT chat_mutes FROM accounts WHERE email=?', [e]);
|
||||
try { const arr = JSON.parse((r[0] && r[0].chat_mutes) || '[]'); return Array.isArray(arr) ? arr : []; } catch (x) { return []; }
|
||||
},
|
||||
async setMute(owner, target, muted) {
|
||||
const cur = await this.getMutes(owner);
|
||||
const set = new Set(cur.map(x => String(x).toLowerCase())); const t = String(target).toLowerCase();
|
||||
if (muted) set.add(t); else set.delete(t);
|
||||
await db.q('UPDATE accounts SET chat_mutes=? WHERE email=?', [JSON.stringify([...set].slice(0, 500)), owner]);
|
||||
return { ok: true, muted: !!muted };
|
||||
},
|
||||
async byMemberId(id) {
|
||||
const r = await db.q('SELECT * FROM accounts WHERE member_id=?', [Number(id)]);
|
||||
return rowPub(r[0]);
|
||||
},
|
||||
async setMemberId(e, id) { await db.q('UPDATE accounts SET member_id=? WHERE email=? AND (member_id IS NULL OR member_id<>?)', [id, e, id]); },
|
||||
async namesForMembers(ids) {
|
||||
if (!ids.length) return {};
|
||||
const rows = await db.q('SELECT member_id, username FROM accounts WHERE username IS NOT NULL AND member_id IN ('
|
||||
+ ids.map(() => '?').join(',') + ')', ids);
|
||||
const out = {};
|
||||
for (const r of rows) out[r.member_id] = r.username;
|
||||
return out;
|
||||
},
|
||||
async listByReferrer(refs) {
|
||||
const clean = refs.filter(Boolean).map(String);
|
||||
if (!clean.length) return [];
|
||||
const rows = await db.q('SELECT * FROM accounts WHERE sponsor_ref IN (' + clean.map(() => '?').join(',')
|
||||
+ ') ORDER BY created DESC LIMIT 200', clean);
|
||||
return rows.map(rowPub);
|
||||
},
|
||||
async linkWallet(e, a) {
|
||||
const cur = await this.byEmail(e);
|
||||
if (!cur) return { error: 'No such account.' };
|
||||
if (cur.address && cur.address !== a) return { error: 'This account is already linked to wallet '
|
||||
+ cur.address.slice(0, 6) + '…' + cur.address.slice(-4) + '. Connect that wallet instead.' };
|
||||
const pos = (await db.q('SELECT email FROM positions WHERE address=?', [a]))[0];
|
||||
if (pos) return { error: 'That wallet is already a linked position' + (pos.email === e ? ' on this account.' : ' on a different account.') };
|
||||
try { await db.q('UPDATE accounts SET address=? WHERE email=?', [a, e]); }
|
||||
catch (err) {
|
||||
if (err.code === 'ER_DUP_ENTRY') return { error: 'That wallet is already linked to a different account.' };
|
||||
throw err;
|
||||
}
|
||||
return { ok: true, account: await this.byEmail(e) };
|
||||
},
|
||||
async listAll(limit) { const rows = await db.q('SELECT * FROM accounts ORDER BY created DESC LIMIT ?', [Number(limit) || 500]); return rows.map(rowPub); },
|
||||
async setSponsorRef(e, ref) {
|
||||
const r = await db.q('UPDATE accounts SET sponsor_ref=? WHERE email=?', [ref, e]);
|
||||
if (!r.affectedRows) return { error: 'No such account.' };
|
||||
return { ok: true, account: await this.byEmail(e) };
|
||||
},
|
||||
async count() { const r = await db.q('SELECT COUNT(*) n FROM accounts'); return Number(r[0].n); },
|
||||
// ---- linked positions (extra wallets on one account) ----
|
||||
async positions(e) {
|
||||
const rows = await db.q('SELECT * FROM positions WHERE email=? ORDER BY created', [e]);
|
||||
return rows.map(r => ({ address: r.address, email: r.email, memberId: r.member_id || 0, created: Number(r.created) }));
|
||||
},
|
||||
async addPosition(e, a) {
|
||||
const acct = await this.byEmail(e);
|
||||
if (!acct) return { error: 'No such account.' };
|
||||
if (!acct.address) return { error: 'Link your main wallet first.' };
|
||||
if (acct.address === a) return { error: 'That is your main wallet. Switch to a different account in your wallet app, then try again.' };
|
||||
if (await this.byAddress(a)) return { error: 'That wallet is already the main wallet of another account.' };
|
||||
const cur = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0];
|
||||
if (cur && cur.email !== e) return { error: 'That wallet is already a position on a different account.' };
|
||||
if (!cur) await db.q('INSERT INTO positions (address,email,member_id,created) VALUES (?,?,0,?)', [a, e, Date.now()]);
|
||||
return { ok: true, address: a, created: !cur };
|
||||
},
|
||||
async setPositionMember(a, id) { await db.q('UPDATE positions SET member_id=? WHERE address=? AND member_id<>?', [id, a, id]); },
|
||||
async positionOwner(a) {
|
||||
const r = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0];
|
||||
return r ? { address: r.address, email: r.email, memberId: r.member_id || 0 } : null;
|
||||
},
|
||||
async positionByMember(id) { const r = await db.q('SELECT address, email, member_id FROM positions WHERE member_id=? LIMIT 1', [Number(id)]); return r[0] ? { address: r[0].address, email: r[0].email, memberId: r[0].member_id } : null; },
|
||||
async removePosition(e, a) {
|
||||
const r = (await db.q('SELECT * FROM positions WHERE address=? AND email=?', [a, e]))[0];
|
||||
if (!r) return { error: 'No such position.' };
|
||||
if (r.member_id) return { error: 'That position is already registered on-chain and cannot be unlinked.' };
|
||||
await db.q('DELETE FROM positions WHERE address=?', [a]); return { ok: true };
|
||||
},
|
||||
async adminSetAddress(e, a) {
|
||||
if (a) {
|
||||
const o = (await db.q('SELECT email FROM accounts WHERE address=?', [a]))[0]; if (o && o.email !== e) return { error: 'That wallet is the main wallet of another account.' };
|
||||
const pos = (await db.q('SELECT email FROM positions WHERE address=?', [a]))[0]; if (pos) return { error: 'That wallet is a linked position on ' + (pos.email === e ? 'this' : 'another') + ' account.' };
|
||||
}
|
||||
await db.q('UPDATE accounts SET address=? WHERE email=?', [a || null, e]); return { ok: true, account: await this.byEmail(e) };
|
||||
},
|
||||
async positionByAddress(a) { const r = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0]; return r ? { address: r.address, email: r.email, memberId: r.member_id || 0 } : null; },
|
||||
async removeAccount(e) {
|
||||
await db.q('DELETE FROM positions WHERE email=?', [e]);
|
||||
const r = await db.q('DELETE FROM accounts WHERE email=?', [e]); return r.affectedRows ? { ok: true } : { error: 'No such account.' };
|
||||
},
|
||||
};
|
||||
|
||||
const impl = () => db.enabled() ? D : J;
|
||||
function init(opts) { DATA_DIR = opts.dataDir; J.load(); }
|
||||
|
||||
async function signup(email, password, sponsorRef) {
|
||||
const e = normEmail(email);
|
||||
if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' };
|
||||
if (String(password || '').length < 8) return { error: 'Password needs at least 8 characters.' };
|
||||
return impl().signup(e, String(password), String(sponsorRef || ''));
|
||||
}
|
||||
async function login(email, password) { return impl().login(normEmail(email), String(password || '')); }
|
||||
async function ensure(email, sponsorRef, via, joinedRef) {
|
||||
const e = normEmail(email);
|
||||
if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' };
|
||||
return impl().ensure(e, String(sponsorRef || ''), String(via || '').toLowerCase().slice(0, 20) || null, String(joinedRef || '').toLowerCase().slice(0, 80) || null);
|
||||
}
|
||||
async function byEmail(email) { return impl().byEmail(normEmail(email)); }
|
||||
async function byAddress(address) { return impl().byAddress(normAddr(address)); }
|
||||
async function byCode(code) { return impl().byCode(String(code || '').toLowerCase()); }
|
||||
async function byUsername(u) {
|
||||
const n = normUser(u);
|
||||
return USER_RE.test(n) ? impl().byUsername(n) : null;
|
||||
}
|
||||
async function setUsername(email, username) {
|
||||
const n = normUser(username);
|
||||
if (!USER_RE.test(n)) return { error: 'Usernames are 3 to 20 letters, numbers, or underscores.' };
|
||||
if (/^\d+$/.test(n)) return { error: 'Usernames need at least one letter.' }; // keep /join/<number> unambiguous
|
||||
return impl().setUsername(normEmail(email), n);
|
||||
}
|
||||
async function setMemberId(email, id) { return impl().setMemberId(normEmail(email), Number(id) || 0); }
|
||||
async function namesForMembers(ids) { return impl().namesForMembers([...new Set(ids)].filter(n => n > 0)); }
|
||||
async function listByReferrer(refs) { return impl().listByReferrer(refs || []); }
|
||||
// the tokens a member could have been joined under (code, username, or numeric id)
|
||||
function refTokens(a) {
|
||||
return [a.code, a.username, a.memberId ? String(a.memberId) : null].filter(Boolean).map(String);
|
||||
}
|
||||
// walk the downline breadth-first to `depth` levels. Returns
|
||||
// [{ level, members:[pub...] }]; emails are included on pub but the CALLER
|
||||
// decides who may see them (directs only, per product rule).
|
||||
async function downline(email, depth = 3) {
|
||||
const root = await byEmail(email);
|
||||
if (!root) return [];
|
||||
const seen = new Set([String(root.email)]);
|
||||
const levels = [];
|
||||
let frontier = [root];
|
||||
for (let lvl = 1; lvl <= depth; lvl++) {
|
||||
const toks = [...new Set(frontier.flatMap(refTokens))];
|
||||
if (!toks.length) break;
|
||||
const kids = (await listByReferrer(toks)).filter(k => !seen.has(String(k.email)));
|
||||
if (!kids.length) break;
|
||||
kids.forEach(k => seen.add(String(k.email)));
|
||||
levels.push({ level: lvl, members: kids });
|
||||
frontier = kids;
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
async function linkWallet(email, address) {
|
||||
const a = normAddr(address);
|
||||
if (!/^0x[0-9a-f]{40}$/.test(a)) return { error: 'Bad wallet address.' };
|
||||
return impl().linkWallet(normEmail(email), a);
|
||||
}
|
||||
async function count() { return impl().count(); }
|
||||
// admin: newest-first account list, and re-pointing a member's sponsor (a
|
||||
// username, share code, or numeric member id: the same tokens join links use)
|
||||
async function listAll(limit = 500) { return impl().listAll(limit); }
|
||||
async function setSponsorRef(email, ref) { return impl().setSponsorRef(normEmail(email), String(ref || '').trim().toLowerCase().slice(0, 40)); }
|
||||
|
||||
// resolve a member's DIRECT sponsor account (the token they joined under)
|
||||
async function sponsorOf(email) {
|
||||
const root = await byEmail(email);
|
||||
if (!root || !root.sponsorRef) return null;
|
||||
const ref = String(root.sponsorRef);
|
||||
let s = null;
|
||||
try { s = await byCode(ref); } catch (e) {}
|
||||
if (!s) { try { s = await byUsername(ref); } catch (e) {} }
|
||||
if (!s && /^\d+$/.test(ref)) { try { s = await byMemberId(Number(ref)); } catch (e) {} }
|
||||
return s || null;
|
||||
}
|
||||
// is `memberEmail` anywhere in `sponsorEmail`'s downline (to `depth` levels)?
|
||||
async function isDownlineOf(sponsorEmail, memberEmail, depth = 3) {
|
||||
const target = String(memberEmail || '').toLowerCase();
|
||||
const levels = await downline(sponsorEmail, depth);
|
||||
return levels.some(L => L.members.some(m => String(m.email || '').toLowerCase() === target));
|
||||
}
|
||||
async function getChatSettings(email) {
|
||||
const a = await byEmail(email);
|
||||
return { available: a ? a.chatAvailable !== false : true, mutes: await impl().getMutes(String(email || '').toLowerCase()) };
|
||||
}
|
||||
|
||||
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername, byMemberId: id => impl().byMemberId(Number(id) || 0), listAll, setSponsorRef,
|
||||
setUsername, setMemberId, namesForMembers, listByReferrer, downline, linkWallet, count,
|
||||
setLineBanner: (e, b, t) => impl().setLineBanner(String(e || '').toLowerCase(), b, t),
|
||||
setWallOffers: (e, j) => impl().setWallOffers(String(e || '').toLowerCase(), j),
|
||||
setProfile: (e, a, bio, socials) => impl().setProfile(String(e || '').toLowerCase(), a, bio, socials),
|
||||
touchSeen: e => impl().touchSeen(String(e || '').toLowerCase()),
|
||||
adminSetAddress: (e, a) => impl().adminSetAddress(String(e || '').toLowerCase(), a ? normAddr(a) : null),
|
||||
positionByAddress: a => impl().positionByAddress(normAddr(a)),
|
||||
removeAccount: e => impl().removeAccount(String(e || '').toLowerCase()),
|
||||
setChatAvailable: (e, v) => impl().setChatAvailable(String(e || '').toLowerCase(), v),
|
||||
getMutes: e => impl().getMutes(String(e || '').toLowerCase()),
|
||||
setMute: (o, t, m) => impl().setMute(String(o || '').toLowerCase(), String(t || '').toLowerCase(), m),
|
||||
sponsorOf, isDownlineOf, getChatSettings,
|
||||
positions: e => impl().positions(normEmail(e)),
|
||||
addPosition: (e, a) => { const x = normAddr(a); return /^0x[0-9a-f]{40}$/.test(x) ? impl().addPosition(normEmail(e), x) : Promise.resolve({ error: 'Bad wallet address.' }); },
|
||||
setPositionMember: (a, id) => impl().setPositionMember(normAddr(a), Number(id) || 0),
|
||||
positionOwner: a => impl().positionOwner(normAddr(a)),
|
||||
positionByMember: id => impl().positionByMember(Number(id) || 0),
|
||||
removePosition: (e, a) => impl().removePosition(normEmail(e), normAddr(a)),
|
||||
byMemberId: id => impl().byMemberId(id) };
|
||||
@@ -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,72 @@
|
||||
// Counter audit (Marty, 2026-09-15: "buyers will lose confidence if they feel cheated"). Every ad format's
|
||||
// recorded impressions are reconciled against the log that proves delivery, and login/featured charging is
|
||||
// checked against actual shows. Runs on demand from Admin > Reports and once a day; any issue alerts the admin.
|
||||
// DB mode only (production); JSON mode reports "not checked".
|
||||
const db = require('./db');
|
||||
const fs = require('fs'); const path = require('path');
|
||||
let R = null; // { notify(text), dataDir }
|
||||
function init(refs) { R = refs; }
|
||||
// credits already returned for past counting errors: subtract them so history that was made right does not keep flagging
|
||||
function refunded() { const out = {}; try { for (const f of fs.readdirSync(R.dataDir)) if (/^refunds-.*\.json$/.test(f)) for (const i of (JSON.parse(fs.readFileSync(path.join(R.dataDir, f), 'utf8')).items || [])) out[i.id] = (out[i.id] || 0) + Number(i.credits || 0); } catch (e) {} return out; }
|
||||
|
||||
async function run() {
|
||||
const out = { checkedAt: Date.now(), checks: [] };
|
||||
if (!db.enabled()) { out.checks.push({ name: 'Counters', ok: true, detail: 'JSON mode: not checked', issues: [] }); return out; }
|
||||
const camps = await db.q("SELECT id, name, type, status, imps, clicks, spent, accrued, budget, created, house FROM campaigns WHERE house=0 OR house IS NULL");
|
||||
const back = refunded(); for (const c of camps) if (back[c.id]) c.spent = Math.max(0, Number(c.spent) - back[c.id]); // already made right
|
||||
const logStart = (await db.q('SELECT MIN(day) d FROM camp_hours'))[0].d; const logStartMs = logStart ? Date.parse(logStart + 'T00:00:00Z') : 0;
|
||||
const byType = t => camps.filter(c => c.type === t);
|
||||
const add = (name, issues, detail) => out.checks.push({ name, ok: !issues.length, detail, issues });
|
||||
// 1. per-view formats: campaign.imps must equal the rows in the log that paid for them
|
||||
for (const [type, table, label] of [['video', 'video_seen', 'completed watches'], ['visits', 'visit_seen', 'verified visits'], ['solo', 'solo_inbox', 'inbox deliveries']]) {
|
||||
const rows = await db.q('SELECT campaign_id, COUNT(*) n FROM ' + table + ' GROUP BY campaign_id'); const logN = {}; for (const r of rows) logN[r.campaign_id] = Number(r.n);
|
||||
const issues = []; let tot = 0, logTot = 0;
|
||||
for (const c of byType(type)) { const l = logN[c.id] || 0; tot += Number(c.imps); logTot += l; if (Number(c.imps) !== l) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': ' + c.imps + ' views vs ' + l + ' ' + label); }
|
||||
add(type + ' views vs ' + label, issues, tot + ' views recorded, ' + logTot + ' ' + label);
|
||||
}
|
||||
// 2. banner/text: imps must equal the hourly log (both bumped on serve)
|
||||
{
|
||||
const rows = await db.q('SELECT campaign_id, SUM(n) n FROM camp_hours GROUP BY campaign_id'); const h = {}; for (const r of rows) h[r.campaign_id] = Number(r.n);
|
||||
const issues = [];
|
||||
// only campaigns that started after the hourly log did
|
||||
for (const c of camps.filter(c => ['banner', 'text'].includes(c.type) && Number(c.created) >= logStartMs)) { const l = h[c.id] || 0; if (Math.abs(Number(c.imps) - l) > Math.max(5, Math.round(0.02 * Math.max(Number(c.imps), l)))) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': ' + c.imps + ' views vs ' + l + ' in the hourly log'); }
|
||||
add('banner/text views vs hourly log', issues, camps.filter(c => ['banner', 'text'].includes(c.type) && Number(c.created) >= logStartMs).length + ' campaigns compared (started since the hourly log began ' + (logStart || '') + ')');
|
||||
}
|
||||
// 3. login: days charged vs days shown (a day may only be charged after the ad was shown)
|
||||
{
|
||||
const rows = await db.q('SELECT campaign_id, COUNT(DISTINCT day) d FROM camp_hours WHERE n>0 GROUP BY campaign_id'); const shown = {}; for (const r of rows) shown[r.campaign_id] = Number(r.d);
|
||||
const issues = [];
|
||||
for (const c of byType('login')) { const charged = Math.round((Number(c.spent) + Number(c.accrued || 0)) / 100), s = shown[c.id] || 0; if (charged > s) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': charged ' + charged + ' day(s), shown on ' + s); }
|
||||
add('login ads: days charged vs days shown', issues, byType('login').length + ' campaigns compared');
|
||||
}
|
||||
// 4. featured: a live booking older than two hours must have views
|
||||
{
|
||||
const issues = []; const now = Date.now();
|
||||
for (const c of byType('featured').filter(c => c.status === 'active' && now - Number(c.created) > 2 * 3600e3)) if (!Number(c.imps)) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': live with 0 views');
|
||||
add('featured: live bookings have views', issues, byType('featured').filter(c => c.status === 'active').length + ' live bookings');
|
||||
}
|
||||
// 5. clicks can only exceed views where a click precedes the counted view (visits, login)
|
||||
{
|
||||
const issues = [];
|
||||
for (const c of camps.filter(c => !['visits', 'login'].includes(c.type) && Number(c.clicks) > Number(c.imps) + 5)) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ' (' + c.type + '): ' + c.clicks + ' clicks vs ' + c.imps + ' views');
|
||||
add('clicks vs views', issues, 'campaigns with more clicks than views, where that cannot happen');
|
||||
}
|
||||
// 6. budgets: spent can never pass budget
|
||||
{
|
||||
const issues = camps.filter(c => Number(c.spent) + Number(c.accrued || 0) > Number(c.budget) + 1).map(c => '#' + c.id + ' ' + c.name.slice(0, 30) + ': spent ' + (Number(c.spent) + Number(c.accrued || 0)) + ' of ' + c.budget);
|
||||
add('spend never exceeds budget', issues, camps.length + ' campaigns');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// the "once a day" memory lives on the volume: a redeploy restarts the process, and Marty got an alert after every deploy (2026-09-15)
|
||||
const STATE = () => path.join(R.dataDir, 'audit-state.json');
|
||||
function lastAlertDay() { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')).lastAlertDay || ''; } catch (e) { return ''; } }
|
||||
function setAlertDay(d) { try { fs.writeFileSync(STATE(), JSON.stringify({ lastAlertDay: d })); } catch (e) {} }
|
||||
async function dailyTick() {
|
||||
try {
|
||||
const r = await run(); const bad = r.checks.filter(c => !c.ok); const day = new Date().toISOString().slice(0, 10);
|
||||
if (bad.length && lastAlertDay() !== day && R && R.notify) { setAlertDay(day); R.notify('⚠️ LinkSpin counter audit found ' + bad.length + ' issue' + (bad.length === 1 ? '' : 's') + ': ' + bad.map(c => c.name + ' (' + c.issues.length + ')').join('; ') + '. Admin > Reports > Counters audit.'); }
|
||||
return r;
|
||||
} catch (e) { console.error('audit', e.message); return null; }
|
||||
}
|
||||
module.exports = { init, run, dailyTick };
|
||||
@@ -0,0 +1,169 @@
|
||||
// Wallet sign-in (SIWE / EIP-4361) + session store for LinkSpin.
|
||||
// Sessions are dual-mode like accounts.js: MySQL when DATABASE_URL is set,
|
||||
// volume JSON otherwise. All session functions are async.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { keccak256 } = require('./vendor/sha3');
|
||||
const secp = require('./vendor/secp256k1');
|
||||
const db = require('./db');
|
||||
|
||||
let DATA_DIR = null;
|
||||
let chain = null;
|
||||
let IS_PROD = false;
|
||||
let SITE = 'linkspin-test.saasy.top';
|
||||
|
||||
const CHALLENGE_TTL = 10 * 60 * 1000;
|
||||
const SESSION_TTL = 24 * 60 * 60 * 1000; // 24h — forces a daily re-login so the login ad shows each day
|
||||
const challenges = new Map(); // addressLower -> {message, exp}
|
||||
|
||||
// ---- JSON session fallback ----
|
||||
const J = {
|
||||
sessions: new Map(),
|
||||
FILE: () => path.join(DATA_DIR, 'sessions.json'),
|
||||
load() {
|
||||
try {
|
||||
const o = JSON.parse(fs.readFileSync(this.FILE(), 'utf8'));
|
||||
this.sessions = new Map(Object.entries(o).filter(([, s]) => s.expires > Date.now()));
|
||||
} catch (e) { this.sessions = new Map(); }
|
||||
},
|
||||
save() {
|
||||
try {
|
||||
const tmp = this.FILE() + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.sessions)), { mode: 0o600 });
|
||||
fs.renameSync(tmp, this.FILE());
|
||||
} catch (e) { console.error('session save failed', e.message); }
|
||||
},
|
||||
async mint(fields) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
this.sessions.set(token, Object.assign({ email: null, address: null, memberId: 0 }, fields,
|
||||
{ expires: Date.now() + SESSION_TTL }));
|
||||
this.save();
|
||||
return token;
|
||||
},
|
||||
async get(token) {
|
||||
const s = this.sessions.get(token);
|
||||
if (!s || s.expires < Date.now()) return null;
|
||||
return Object.assign({ token }, s);
|
||||
},
|
||||
async update(token, fields) {
|
||||
const s = this.sessions.get(token);
|
||||
if (!s) return;
|
||||
this.sessions.set(token, Object.assign({}, s, fields));
|
||||
this.save();
|
||||
},
|
||||
async drop(token) { this.sessions.delete(token); this.save(); }
|
||||
};
|
||||
|
||||
// ---- MySQL session mode ----
|
||||
const D = {
|
||||
async mint(fields) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
await db.q('INSERT INTO sessions (token,email,address,member_id,expires) VALUES (?,?,?,?,?)',
|
||||
[token, fields.email || null, fields.address || null, fields.memberId || 0, Date.now() + SESSION_TTL]);
|
||||
return token;
|
||||
},
|
||||
async get(token) {
|
||||
const rows = await db.q('SELECT * FROM sessions WHERE token=? AND expires>?', [token, Date.now()]);
|
||||
if (!rows.length) return null;
|
||||
const r = rows[0];
|
||||
return { token, email: r.email, address: r.address, memberId: r.member_id, expires: Number(r.expires) };
|
||||
},
|
||||
async update(token, fields) {
|
||||
const sets = [], vals = [];
|
||||
if ('email' in fields) { sets.push('email=?'); vals.push(fields.email); }
|
||||
if ('address' in fields) { sets.push('address=?'); vals.push(fields.address); }
|
||||
if ('memberId' in fields) { sets.push('member_id=?'); vals.push(fields.memberId); }
|
||||
if (!sets.length) return;
|
||||
vals.push(token);
|
||||
await db.q('UPDATE sessions SET ' + sets.join(',') + ' WHERE token=?', vals);
|
||||
},
|
||||
async drop(token) { await db.q('DELETE FROM sessions WHERE token=?', [token]); }
|
||||
};
|
||||
const impl = () => db.enabled() ? D : J;
|
||||
|
||||
function init(opts) {
|
||||
DATA_DIR = opts.dataDir; chain = opts.chain; IS_PROD = !!opts.isProd;
|
||||
if (opts.site) SITE = opts.site;
|
||||
J.load();
|
||||
}
|
||||
|
||||
// ---- crypto ----
|
||||
function personalDigest(msg) {
|
||||
const m = Buffer.from(msg, 'utf8');
|
||||
const pre = Buffer.from('\x19Ethereum Signed Message:\n' + m.length, 'utf8');
|
||||
return Buffer.from(keccak256(Buffer.concat([pre, m])), 'hex');
|
||||
}
|
||||
function recoverAddress(msg, signature) {
|
||||
const raw = Buffer.from(String(signature).replace(/^0x/, ''), 'hex');
|
||||
if (raw.length !== 65) throw new Error('Bad signature length');
|
||||
let v = raw[64]; if (v >= 27) v -= 27;
|
||||
if (v !== 0 && v !== 1) throw new Error('Bad signature recovery byte');
|
||||
const pub = secp.recoverPublicKey(personalDigest(msg), raw.slice(0, 64), v, false);
|
||||
return '0x' + keccak256(Buffer.from(pub.slice(1))).slice(-40);
|
||||
}
|
||||
const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
|
||||
function checksumAddress(address) {
|
||||
const a = address.toLowerCase().replace(/^0x/, '');
|
||||
const h = keccak256(a);
|
||||
let out = '0x';
|
||||
for (let i = 0; i < a.length; i++) out += parseInt(h[i], 16) >= 8 ? a[i].toUpperCase() : a[i];
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- SIWE flow ----
|
||||
function makeChallenge(address) {
|
||||
if (!ADDR_RE.test(address || '')) return { error: 'Bad address' };
|
||||
const a = address.toLowerCase();
|
||||
const nonce = crypto.randomBytes(16).toString('hex');
|
||||
const chainId = chain.getConfig().chainId;
|
||||
const message = `${SITE} wants you to sign in with your Ethereum account:\n${checksumAddress(a)}\n\nLinkSpin member sign-in. This signature is free and cannot move funds or approve anything.\n\nURI: https://${SITE}\nVersion: 1\nChain ID: ${chainId}\nNonce: ${nonce}\nIssued At: ${new Date().toISOString()}`;
|
||||
challenges.set(a, { message, exp: Date.now() + CHALLENGE_TTL });
|
||||
return { message };
|
||||
}
|
||||
async function verifyChallenge(address, signature) {
|
||||
const a = (address || '').toLowerCase();
|
||||
const ch = challenges.get(a);
|
||||
if (!ch || ch.exp < Date.now()) return { error: 'Challenge expired - tap sign-in again.' };
|
||||
let rec;
|
||||
try { rec = recoverAddress(ch.message, signature); } catch (e) { return { error: 'Invalid signature: ' + e.message }; }
|
||||
if (rec !== a) return { error: 'Your wallet signed with a different account than the page is using ('
|
||||
+ rec.slice(0, 6) + '…' + rec.slice(-4) + '). Switch accounts and tap sign-in again.' };
|
||||
challenges.delete(a);
|
||||
return { ok: true, address: a };
|
||||
}
|
||||
|
||||
// ---- sessions ----
|
||||
async function mintSession(fields) { return impl().mint(fields || {}); }
|
||||
async function updateSession(token, fields) { return impl().update(token, fields || {}); }
|
||||
function sessionCookie(token) {
|
||||
return `iap.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}${IS_PROD ? '; Secure' : ''}`;
|
||||
}
|
||||
function clearCookie() { return 'iap.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; }
|
||||
async function fromRequest(req) {
|
||||
const m = /(?:^|;\s*)iap\.sid=([^;]+)/.exec(req.headers.cookie || '');
|
||||
if (!m) return null;
|
||||
return impl().get(decodeURIComponent(m[1]));
|
||||
}
|
||||
async function refreshMemberId(sess) {
|
||||
if (!sess) return 0;
|
||||
if (!sess.address) {
|
||||
if (sess.memberId) return sess.memberId;
|
||||
// email-only session (phone sign-in with no wallet connected): read the account's
|
||||
// on-chain id instead, so read-only views (My line, earnings, P&L) work from any
|
||||
// device. Not cached into the session: a session without an address stays wallet-less.
|
||||
if (sess.email) { try { const a = await require('./accounts').byEmail(sess.email); if (a && a.memberId) return a.memberId; } catch (e) {} }
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
const id = await chain.memberIdByAccount(sess.address);
|
||||
if (id && id !== sess.memberId) await updateSession(sess.token, { memberId: id });
|
||||
return id || sess.memberId || 0;
|
||||
} catch (e) { return sess.memberId || 0; }
|
||||
}
|
||||
async function logout(req) {
|
||||
const s = await fromRequest(req);
|
||||
if (s) await impl().drop(s.token);
|
||||
}
|
||||
|
||||
module.exports = { init, makeChallenge, verifyChallenge, mintSession, updateSession, sessionCookie, clearCookie, fromRequest, refreshMemberId, logout };
|
||||
@@ -0,0 +1,162 @@
|
||||
// Public blog (Marty, 2026-09-12): coaching and teaching articles on linkspin-test.saasy.top, written in
|
||||
// Admin > Blog, server-rendered so crawlers see real HTML with real metadata. Storage: MySQL
|
||||
// blog_posts, or DATA_DIR/blog.json. Public: /blog (paged index), /blog/<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://linkspin-test.saasy.top';
|
||||
const AUTHOR = 'Marty Bostick';
|
||||
const PER_PAGE = 10;
|
||||
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const slugify = s => String(s || '').toLowerCase().replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
||||
const words = html => String(html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const readMinutes = html => Math.max(1, Math.round(words(html).split(' ').length / 220));
|
||||
const fmtDate = ts => new Date(ts).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Chicago' });
|
||||
|
||||
// article HTML from the admin editor: keep a small whitelist of tags, http(s) links and safe images
|
||||
function sanitize(html) {
|
||||
const src = String(html || '').replace(/ /g, ' ').replace(/<(script|style|iframe|object|embed|form)\b[\s\S]*?<\/\1\s*>/gi, '').replace(/<(script|style|iframe|object|embed|form|input)\b[^>]*>/gi, '').replace(/<!--[\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="LinkSpin"><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="LinkSpin 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=20260914a"></script></body></html>';
|
||||
}
|
||||
const authorBox = () => '<div class="author"><img src="/logo-icon.png" alt="LinkSpin"><div><b>' + AUTHOR + '</b><span>Founder of LinkSpin 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 | LinkSpin';
|
||||
const desc = 'Coaching and teaching articles from Marty Bostick on building a line, advertising that pays, and doing the simple work every day.';
|
||||
const p = tag ? '/blog/tag/' + encodeURIComponent(tag) : '/blog' + (page > 1 ? '/page/' + page : '');
|
||||
let h = head({ title, desc, path: p });
|
||||
h += '<section class="hero" style="padding:56px 0 8px"><p class="eyebrow">' + (tag ? 'Tag: ' + esc(tag) : 'The LinkSpin 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: 'LinkSpin blog', url: SITE + '/blog', publisher: { '@type': 'Organization', name: 'LinkSpin', logo: SITE + '/logo.png' } }) + '</script>';
|
||||
return h + tail();
|
||||
}
|
||||
function renderPost(p, related) {
|
||||
const url = SITE + '/blog/' + p.slug;
|
||||
let h = head({ title: p.title + ' | LinkSpin', desc: p.excerpt, path: '/blog/' + p.slug, image: p.cover, article: p });
|
||||
h += '<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">LinkSpin 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: 'LinkSpin', 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>LinkSpin 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'];
|
||||
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 };
|
||||
@@ -0,0 +1,138 @@
|
||||
// Automatic credit burner: settles pending campaign spend on-chain by calling
|
||||
// consume() from the engine signer. Inert unless ENGINE_KEY (hex private key)
|
||||
// or ENGINE_KEY_FILE is set. Purchased credits only ever go DOWN via this path,
|
||||
// and members never sign or pay gas for it: the engine wallet pays.
|
||||
let ethers = null; try { ethers = require('ethers'); } catch (e) { /* optional dependency */ }
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
// in-flight sends survive a restart: written to the volume before every send, cleared on receipt
|
||||
const INFLIGHT_FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'burner-inflight.json');
|
||||
function loadInflight() { try { return JSON.parse(fs.readFileSync(INFLIGHT_FILE(), 'utf8')); } catch (e) { return {}; } }
|
||||
function saveInflight(o) { try { fs.writeFileSync(INFLIGHT_FILE(), JSON.stringify(o)); } catch (e) {} }
|
||||
|
||||
let chain = null, ads = null, accounts = null;
|
||||
// another of the same account's positions that can cover this burn on-chain (credits are pooled per account)
|
||||
async function fundedAlternative(b) {
|
||||
if (!accounts) return null;
|
||||
const owner = await ads.burnOwner(b.ref); if (!owner) return null;
|
||||
const acct = await accounts.byEmail(owner); if (!acct) return null;
|
||||
const ids = [acct.memberId, ...(await accounts.positions(owner)).map(p => p.memberId)].filter(id => id && id !== b.memberId);
|
||||
for (const id of [...new Set(ids)]) {
|
||||
try { const bal = await chain.creditBalance(id, 0); const held = await ads.unburnedFor(id); if (bal - held >= b.amount) return id; } catch (e) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const ABI = ['function consume(uint32 memberId_, uint8 creditType, uint256 amount, bytes32 campaignRef)', 'function engineSigner() view returns (address)'];
|
||||
const state = { enabled: false, address: null, signer: null, balanceWei: '0', lastRun: 0, lastError: null, burned: 0, lastTx: null, mismatch: false, skipped: {} };
|
||||
let running = false;
|
||||
|
||||
function keyHex() {
|
||||
let k = String(process.env.ENGINE_KEY || '').trim();
|
||||
if (!k && process.env.ENGINE_KEY_FILE) { try { k = fs.readFileSync(process.env.ENGINE_KEY_FILE, 'utf8').trim(); } catch (e) {} }
|
||||
if (!k) return null;
|
||||
if (!k.startsWith('0x')) k = '0x' + k;
|
||||
return /^0x[0-9a-fA-F]{64}$/.test(k) ? k : null;
|
||||
}
|
||||
let rpcIdx = 0;
|
||||
function provider() {
|
||||
const c = chain.getConfig();
|
||||
const urls = (c.rpcs && c.rpcs.length) ? c.rpcs : [c.rpc];
|
||||
return new ethers.JsonRpcProvider(urls[rpcIdx % urls.length], Number(c.chainId), { staticNetwork: true });
|
||||
}
|
||||
function rotateRpc() { const c = chain.getConfig(); const n = (c.rpcs && c.rpcs.length) || 1; rpcIdx = (rpcIdx + 1) % n; }
|
||||
async function setup() {
|
||||
const k = keyHex();
|
||||
if (!k || !ethers) { state.enabled = false; return; }
|
||||
const w = new ethers.Wallet(k, provider());
|
||||
state.address = w.address; state.enabled = true;
|
||||
try {
|
||||
const ctr = new ethers.Contract(chain.getConfig().contract, ABI, w);
|
||||
const es = await ctr.engineSigner();
|
||||
state.mismatch = String(es).toLowerCase() !== w.address.toLowerCase();
|
||||
if (state.mismatch) console.error('burner: ENGINE_KEY address', w.address, 'is not the contract engineSigner', es, '- burns will revert; disabled');
|
||||
} catch (e) { state.lastError = 'engineSigner read: ' + e.message; }
|
||||
}
|
||||
function refToBytes32(ref) { return ethers.zeroPadBytes(ethers.toUtf8Bytes(String(ref || '').slice(0, 32)), 32); }
|
||||
// the on-chain ref IS the burn id, so a burn that already mined can always be recognised
|
||||
// from its CreditsConsumed event, even when the RPC lost the response (a lost response
|
||||
// double-burned member #5's campaign 30 on 2026-09-10)
|
||||
function alreadyMined(b) {
|
||||
const want = refToBytes32(b.id).toLowerCase();
|
||||
const hit = chain.recentEvents(1e9).find(e => e.type === 'CreditsConsumed' && e.memberId === Number(b.memberId) && String(e.ref || '').toLowerCase() === want);
|
||||
return hit ? hit.tx : null;
|
||||
}
|
||||
// ask the chain directly (not the index) whether this burn's ref already appears in a
|
||||
// CreditsConsumed log for this member over roughly the last two hours
|
||||
const TOPIC_CONSUMED = '0x97f58994fda6236f3659a1d723c3d42e81841551eae9243477a2948eac6aec46';
|
||||
async function minedOnChain(b) {
|
||||
const want = refToBytes32(b.id).toLowerCase();
|
||||
const latest = parseInt(await chain.rpc('eth_blockNumber', []), 16);
|
||||
const from = Math.max(0, latest - 3600);
|
||||
const member = '0x' + Number(b.memberId).toString(16).padStart(64, '0');
|
||||
const logs = await chain.rpc('eth_getLogs', [{ address: chain.getConfig().contract, fromBlock: '0x' + from.toString(16), toBlock: 'latest', topics: [TOPIC_CONSUMED, member] }]);
|
||||
for (const lg of logs || []) {
|
||||
const d = String(lg.data || '').slice(2);
|
||||
const ref = '0x' + d.slice(128, 192);
|
||||
if (ref.toLowerCase() === want) return lg.transactionHash;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (!state.enabled || state.mismatch || running || !ethers) return { burned: 0 };
|
||||
running = true;
|
||||
let burned = 0;
|
||||
try {
|
||||
const w = new ethers.Wallet(keyHex(), provider());
|
||||
const ctr = new ethers.Contract(chain.getConfig().contract, ABI, w);
|
||||
state.balanceWei = (await w.provider.getBalance(w.address)).toString();
|
||||
const pending = await ads.pendingBurns();
|
||||
state.lastRun = Date.now();
|
||||
if (BigInt(state.balanceWei) < ethers.parseEther('0.05')) { state.lastError = 'engine wallet low on POL for gas'; return { burned: 0 }; }
|
||||
for (const b of pending.slice(0, 20)) {
|
||||
try {
|
||||
// Polygon nodes reject low priority fees and some public RPCs answer fee
|
||||
// queries with 500s: set the fees ourselves from the server's estimate
|
||||
const mined = alreadyMined(b);
|
||||
if (mined) { await ads.markBurned(b.id, mined); burned += 1; state.burned += 1; continue; }
|
||||
state.inflight = Object.assign(loadInflight(), state.inflight || {});
|
||||
if (state.inflight[b.id]) {
|
||||
// a send whose answer we lost (or a restart mid-send): ask the chain itself before doing anything
|
||||
let tx = null; try { tx = await minedOnChain(b); } catch (e) { state.lastError = 'chain check: ' + e.message; break; }
|
||||
if (tx) { await ads.markBurned(b.id, tx); delete state.inflight[b.id]; saveInflight(state.inflight); burned += 1; state.burned += 1; continue; }
|
||||
if (Date.now() - state.inflight[b.id] < 10 * 60000) continue; // give the network time; re-check next tick
|
||||
}
|
||||
// dry-run first: a revert here (usually "Insufficient credits", the member's on-chain
|
||||
// balance is below what the engine metered) costs no gas and is left for the admin
|
||||
try { await ctr.consume.staticCall(Number(b.memberId), 0, BigInt(b.amount), refToBytes32(b.id)); }
|
||||
catch (e) {
|
||||
const why = String(e.reason || e.shortMessage || e.message).slice(0, 120);
|
||||
// the pinned position is dry: settle from another funded position on the same account
|
||||
const alt = /insufficient credits/i.test(why) ? await fundedAlternative(b) : null;
|
||||
if (!alt) { state.skipped[b.id] = why + (alt === null && /insufficient credits/i.test(why) ? ' (no funded position on the account)' : ''); continue; }
|
||||
try { await ctr.consume.staticCall(Number(alt), 0, BigInt(b.amount), refToBytes32(b.id)); }
|
||||
catch (e2) { state.skipped[b.id] = String(e2.reason || e2.shortMessage || e2.message).slice(0, 120); continue; }
|
||||
await ads.reassignBurn(b.id, alt); b.memberId = alt; delete state.skipped[b.id];
|
||||
}
|
||||
const g = await chain.suggestedFees();
|
||||
const overrides = { gasLimit: 120000n, maxPriorityFeePerGas: BigInt(g.maxPriorityFeePerGas), maxFeePerGas: BigInt(g.maxFeePerGas) };
|
||||
state.inflight[b.id] = Date.now(); saveInflight(state.inflight);
|
||||
const tx = await ctr.consume(Number(b.memberId), 0, BigInt(b.amount), refToBytes32(b.id), overrides);
|
||||
const rc = await tx.wait(1);
|
||||
if (rc && rc.status === 1) { await ads.markBurned(b.id, tx.hash); delete state.inflight[b.id]; saveInflight(state.inflight); burned += 1; state.burned += 1; state.lastTx = tx.hash; state.lastError = null; }
|
||||
else { state.lastError = 'consume reverted for burn ' + b.id; break; }
|
||||
} catch (e) {
|
||||
state.lastError = 'burn ' + b.id + ': ' + String(e.shortMessage || e.message).slice(0, 160);
|
||||
// a node error (500, timeout, rate limit): move to the next RPC for the next tick.
|
||||
// an "Insufficient credits" revert means the member's on-chain balance is
|
||||
// already lower than the engine thinks; leave it pending for the admin to review
|
||||
if (/server response|timeout|rate|429|503|502|500/i.test(String(e.message))) { rotateRpc(); break; }
|
||||
state.skipped[b.id] = String(e.reason || e.shortMessage || e.message).slice(0, 120);
|
||||
}
|
||||
}
|
||||
} finally { running = false; }
|
||||
return { burned };
|
||||
}
|
||||
function status() { return Object.assign({}, state, { hasEthers: !!ethers, keyPresent: !!keyHex() }); }
|
||||
function init(opts) { chain = opts.chain; ads = opts.ads; accounts = opts.accounts || null; setup().catch(e => { state.lastError = e.message; }); }
|
||||
module.exports = { init, tick, status };
|
||||
@@ -0,0 +1,159 @@
|
||||
// Sponsor carry-over (LinkSpin, 2026-09-15). Marty's rule: a sponsor is never passed over for
|
||||
// being absent, only for having no wallet.
|
||||
// - At join: if the registry knows the email and no invite link was used, the member's
|
||||
// InstantAdPay sponsor becomes their LinkSpin sponsor. If that sponsor has no LinkSpin
|
||||
// account yet, a shell account is created for them (email, username, wallet from the
|
||||
// registry) so the line exists on the site immediately; it becomes theirs the first time
|
||||
// they sign in with that email. The sponsor is told.
|
||||
// - Before a member's first activation or purchase: if the sponsor has a wallet but no
|
||||
// position on this contract, the engine activates them on their behalf (activateFor,
|
||||
// engine-signed, gas paid by the engine), walking up until it reaches someone already
|
||||
// on-chain. The sponsor is told, and their first payout email follows on its own.
|
||||
// - Sponsor with no wallet at all: the claim window. The member can do everything except
|
||||
// buy; the sponsor has CLAIM_DAYS to link a wallet. After that the seat walks up to the
|
||||
// nearest ancestor with a wallet and both sides are told.
|
||||
let ethers = null; try { ethers = require('ethers'); } catch (e) {}
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let X = {}; // { dataDir, accounts, registry, chain, mailer, messages, siteConfig, adminEmail }
|
||||
const DAY = 86400000;
|
||||
const ABI = ['function activateFor(address account, uint32 sponsorId) returns (uint32)', 'function engineSigner() view returns (address)'];
|
||||
const norm = e => String(e || '').trim().toLowerCase();
|
||||
|
||||
function init(opts) { X = opts; }
|
||||
function claimDays() { const n = Number((X.siteConfig && X.siteConfig().carryClaimDays) || 3); return n > 0 ? n : 3; }
|
||||
const STATE = () => path.join(X.dataDir, 'carry-state.json');
|
||||
function state() { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')); } catch (e) { return { claims: {}, log: [] }; } }
|
||||
function setState(s) { try { fs.writeFileSync(STATE(), JSON.stringify(s)); } catch (e) {} }
|
||||
function logEvent(ev) { const s = state(); s.log = (s.log || []).concat([Object.assign({ ts: Date.now() }, ev)]).slice(-500); setState(s); }
|
||||
|
||||
function keyHex() {
|
||||
let k = String(process.env.ENGINE_KEY || '').trim();
|
||||
if (!k && process.env.ENGINE_KEY_FILE) { try { k = fs.readFileSync(process.env.ENGINE_KEY_FILE, 'utf8').trim(); } catch (e) {} }
|
||||
if (!k) return null; if (!k.startsWith('0x')) k = '0x' + k;
|
||||
return /^0x[0-9a-fA-F]{64}$/.test(k) ? k : null;
|
||||
}
|
||||
function engineReady() { return !!(ethers && keyHex()); }
|
||||
function signer() {
|
||||
const c = X.chain.getConfig(); const urls = (c.rpcs && c.rpcs.length) ? c.rpcs : [c.rpc];
|
||||
const p = new ethers.JsonRpcProvider(urls[0], Number(c.chainId), { staticNetwork: true });
|
||||
return new ethers.Wallet(keyHex(), p);
|
||||
}
|
||||
|
||||
// ---- notices: email + on-site inbox, sent as the company account ----
|
||||
async function tell(email, subject, text) {
|
||||
try { if (X.mailer && X.mailer.hasKey()) X.mailer.send(email, subject, text + '\n\nLinkSpin').catch(() => {}); } catch (e) {}
|
||||
try {
|
||||
const html = '<p>' + String(text).replace(/&/g, '&').replace(/</g, '<').replace(/(https:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>').split('\n\n').join('</p><p>').replace(/\n/g, '<br>') + '</p>';
|
||||
if (X.messages) await X.messages.deliver(1, X.adminEmail || 'house@linkspin.co', [email], subject, html);
|
||||
} catch (e) {}
|
||||
}
|
||||
const label = a => a ? (a.username ? '@' + a.username : a.email.replace(/^(.).*(@.*)$/, '$1***$2')) : 'a member';
|
||||
|
||||
// ---- join: give the member their network sponsor if no link was used ----
|
||||
async function onJoin(acct, usedRef) {
|
||||
try {
|
||||
const reg = await X.registry.get(acct.email);
|
||||
// carry the member's own facts (username, wallet) forward when the site does not have them yet
|
||||
if (reg && reg.username && !acct.username) { try { await X.accounts.setUsername(acct.email, reg.username); } catch (e) {} }
|
||||
if (usedRef || !reg || !reg.sponsorEmail) return { carried: false };
|
||||
const spReg = await X.registry.get(reg.sponsorEmail); if (!spReg) return { carried: false };
|
||||
let sp = await X.accounts.byEmail(spReg.email);
|
||||
if (!sp) {
|
||||
// shell account for the sponsor: exists on the site now, becomes theirs at first sign-in
|
||||
const r = await X.accounts.ensure(spReg.email, '', 'carried', 'network');
|
||||
sp = r.account;
|
||||
if (spReg.username && !sp.username) { try { await X.accounts.setUsername(spReg.email, spReg.username); sp = await X.accounts.byEmail(spReg.email); } catch (e) {} }
|
||||
if (spReg.wallet && !sp.address) { try { await X.accounts.linkWallet(spReg.email, spReg.wallet); sp = await X.accounts.byEmail(spReg.email); } catch (e) {} }
|
||||
// give the shell its own network sponsor too, so the line keeps climbing
|
||||
if (spReg.sponsorEmail) { const up = await X.accounts.byEmail(spReg.sponsorEmail); const upReg = await X.registry.get(spReg.sponsorEmail); const tok = up ? (up.username || up.code) : (upReg && upReg.username); if (tok) await X.accounts.setSponsorRef(spReg.email, tok); }
|
||||
}
|
||||
const tok = sp.username || sp.code;
|
||||
await X.accounts.setSponsorRef(acct.email, tok);
|
||||
logEvent({ type: 'carried', member: acct.email, sponsor: sp.email });
|
||||
await tell(sp.email, label(acct) + ' just joined LinkSpin under you',
|
||||
'Your InstantAdPay referral ' + label(acct) + ' just joined LinkSpin, and the network placed them under you, the same sponsor line you already built.\n\n'
|
||||
+ (sp.address ? 'Your LinkSpin position will be set up for you the moment it is needed, so you are paid from their first purchase whether or not you have signed in yet. ' : 'Link a wallet on LinkSpin within ' + claimDays() + ' days to be their sponsor on this property; without a wallet there is nothing for the contract to pay. ')
|
||||
+ 'Sign in with this email to see your line: https://' + (X.host || 'linkspin.co') + '/my');
|
||||
return { carried: true, sponsor: sp.email };
|
||||
} catch (e) { return { carried: false, error: e.message }; }
|
||||
}
|
||||
|
||||
// ---- before activation / purchase: make sure the sponsor exists on this contract ----
|
||||
// returns { sponsorId, hold, claim } — hold is set when the buy must wait (claim window)
|
||||
async function resolveForChain(acct, spd) {
|
||||
if (!acct || !acct.sponsorRef) return null;
|
||||
if (spd.id) return null; // already on-chain: nothing to do
|
||||
if (spd.reason !== 'notActivated') return null; // unknown token or rpc: the normal guard handles it
|
||||
let sp = await X.accounts.byCode(acct.sponsorRef); if (!sp) sp = await X.accounts.byUsername(acct.sponsorRef);
|
||||
if (!sp) return null;
|
||||
if (sp.address) {
|
||||
const id = await ensureOnChain(sp, 0);
|
||||
return id ? { sponsorId: id } : null;
|
||||
}
|
||||
// no wallet: the claim window
|
||||
const st = state(); const k = norm(acct.email);
|
||||
let c = st.claims[k];
|
||||
if (!c) {
|
||||
c = { member: k, sponsor: sp.email, opened: Date.now(), deadline: Date.now() + claimDays() * DAY, notified: true }; st.claims[k] = c; setState(st);
|
||||
await tell(sp.email, label(acct) + ' is ready to buy on LinkSpin. Link a wallet to be their sponsor',
|
||||
label(acct) + ' joined LinkSpin under you and wants to buy a package. The contract can only pay a wallet, and there is none on your LinkSpin account yet.\n\nLink one within ' + claimDays() + ' days (Wallet tab, one free signature) and their purchases pay you. If the window passes, the seat goes to the next person above you who has a wallet.\n\nhttps://' + (X.host || 'linkspin.co') + '/my#wallet');
|
||||
}
|
||||
if (Date.now() < c.deadline) return { sponsorId: 0, hold: true, claim: { sponsor: label(sp), deadline: c.deadline } };
|
||||
// window passed: walk up to the nearest ancestor with a wallet, activate them, tell both sides
|
||||
const up = await nearestWithWallet(sp.email);
|
||||
if (!up) return { sponsorId: 0, hold: true, claim: { sponsor: label(sp), deadline: c.deadline, nobody: true } };
|
||||
const id = await ensureOnChain(up, 0);
|
||||
if (!id) return null;
|
||||
await X.accounts.setSponsorRef(acct.email, up.username || up.code);
|
||||
delete st.claims[k]; setState(st); logEvent({ type: 'walkedUp', member: acct.email, from: sp.email, to: up.email });
|
||||
await tell(sp.email, 'The seat for ' + label(acct) + ' passed to ' + label(up), 'No wallet was linked within ' + claimDays() + ' days, so ' + label(acct) + ' was placed under ' + label(up) + ', the nearest person above you with a wallet. Link a wallet now and everyone who joins under you from here on is yours.');
|
||||
await tell(up.email, label(acct) + ' was placed under you on LinkSpin', label(acct) + ' joined through ' + label(sp) + ', who has no wallet on LinkSpin, so the network placed them under you. Their purchases pay you from now on.');
|
||||
return { sponsorId: id, movedTo: up.email };
|
||||
}
|
||||
|
||||
async function nearestWithWallet(email) {
|
||||
let cur = await X.accounts.byEmail(email); const seen = new Set([norm(email)]); let hops = 0;
|
||||
while (cur && cur.sponsorRef && hops < 25) {
|
||||
let sp = await X.accounts.byCode(cur.sponsorRef); if (!sp) sp = await X.accounts.byUsername(cur.sponsorRef);
|
||||
if (!sp || seen.has(norm(sp.email))) return null; seen.add(norm(sp.email)); hops++;
|
||||
if (sp.address) return sp; cur = sp;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// activate `acct` on this contract via the engine, after its own sponsor is there (recursive, bounded)
|
||||
async function ensureOnChain(acct, depth) {
|
||||
if (!acct || !acct.address) return 0;
|
||||
try { const id = await X.chain.memberIdByAccount(acct.address); if (id) return id; } catch (e) {}
|
||||
if (!engineReady() || depth > 25) return 0;
|
||||
let sponsorId = 0;
|
||||
if (acct.sponsorRef) {
|
||||
let sp = await X.accounts.byCode(acct.sponsorRef); if (!sp) sp = await X.accounts.byUsername(acct.sponsorRef);
|
||||
if (sp && sp.address) sponsorId = await ensureOnChain(sp, depth + 1);
|
||||
}
|
||||
if (!sponsorId) sponsorId = Number((X.siteConfig && X.siteConfig().defaultSponsorId) || 1) || 1;
|
||||
try {
|
||||
const w = signer(); const ctr = new ethers.Contract(X.chain.getConfig().contract, ABI, w);
|
||||
const tx = await ctr.activateFor(acct.address, sponsorId, { gasLimit: 160000n });
|
||||
await tx.wait(1);
|
||||
const id = await X.chain.memberIdByAccount(acct.address);
|
||||
logEvent({ type: 'activated', email: acct.email, id, sponsorId, tx: tx.hash });
|
||||
await tell(acct.email, 'We set up your LinkSpin position so you would not miss a payout',
|
||||
'Someone in your line was about to buy on LinkSpin, so the network activated your position on the LinkSpin contract for your wallet ' + acct.address.slice(0, 6) + '…' + acct.address.slice(-4) + ', bound to your own sponsor. Nothing was charged and nothing else was touched. Your share of that purchase lands in this wallet in the same transaction.\n\nSee your line: https://' + (X.host || 'linkspin.co') + '/my');
|
||||
return id;
|
||||
} catch (e) { logEvent({ type: 'activateFailed', email: acct.email, error: String(e.shortMessage || e.message).slice(0, 160) }); return 0; }
|
||||
}
|
||||
|
||||
// pre-seed: activate every account with a wallet, sponsors before their downline
|
||||
async function seedAll(limit) {
|
||||
const all = await X.accounts.listAll(5000);
|
||||
const withWallet = all.filter(a => a.address);
|
||||
let done = 0, failed = 0;
|
||||
for (const a of withWallet) { if (limit && done + failed >= limit) break; const id = await ensureOnChain(a, 0); if (id) done++; else failed++; }
|
||||
return { candidates: withWallet.length, done, failed };
|
||||
}
|
||||
function status() { const s = state(); return { engine: engineReady(), claims: Object.values(s.claims || {}), log: (s.log || []).slice(-50).reverse(), claimDays: claimDays() }; }
|
||||
|
||||
module.exports = { init, onJoin, resolveForChain, ensureOnChain, seedAll, status, engineReady };
|
||||
@@ -0,0 +1,276 @@
|
||||
// On-chain reader + indexer for the LinkSpin contract.
|
||||
// Free public RPCs only, zero npm dependencies (RM Circle pattern).
|
||||
//
|
||||
// Two jobs:
|
||||
// 1. READS: member/product/quote lookups via eth_call.
|
||||
// 2. LIVE TAIL: eth_getLogs over the recent window feeds the public
|
||||
// transparency ledger. State persists in DATA_DIR across redeploys.
|
||||
//
|
||||
// The contract address + chain live in data/config.json so the SAME code
|
||||
// runs the Amoy dress rehearsal and, later, mainnet (flip config, wipe DB).
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
||||
const STATE_FILE = path.join(DATA_DIR, 'chain-index.json');
|
||||
|
||||
// keccak-256 topic hashes, precomputed with cast 2026-09-04
|
||||
const TOPICS = {
|
||||
'0x271ca08b7d4244a2c931d4d329c113aab66e22de98163d90ed791a667735864e': 'MemberActivated',
|
||||
'0x11dc109cafd0f24c81621f745383ec82be7f2947c650355791e27b56ac83bc8c': 'Purchase',
|
||||
'0x5bbc207bba1439ff320c25b90f91258bfaeafb156b09481f148da52be4d7ce8e': 'TierPaid',
|
||||
'0x628405f02369b6b9fc70c1c84e675613626898bcfa536383fdb33695cfd68f7d': 'PassedUp',
|
||||
'0x3999769fc9743f7d4fe9e264d6e9575e8613d17ca29ea7d887b341e17238e0d1': 'AdminPaid',
|
||||
'0x980b1d1cb448ce10b9e9f6f41af3fe5610e4084378c81fd0e0a7f5bbf9360bf8': 'BuyerCounted',
|
||||
'0x97f58994fda6236f3659a1d723c3d42e81841551eae9243477a2948eac6aec46': 'CreditsConsumed',
|
||||
'0x756a68c7a9e11c294245f97f39923944a5d81db3794102f15ca600934e88ae23': 'AwardPaid',
|
||||
'0x1f044acf816321a559e13e967225bcf188d869107e381d2b793a469f122c0f69': 'ProductAdded',
|
||||
'0xafb990f51e0a69f1a1c2ae42a5dc54543e9fafc12ba1c95d130737502070b783': 'PriceChangeQueued',
|
||||
'0xfa5bbf62287a1aea9b1e3ed371e906f83229f094c5fe8a39c73036f557189580': 'PriceChanged',
|
||||
'0xc361eff50f1c2ee869e4ddb66dede88a03a9471fb738c680d55fe6ac37f8d459': 'ProductRetired',
|
||||
'0x3b00801a940479d5435f6ef82acfc5071c5e9bdcddac5150ed53f299366ddd51': 'ProductReactivated',
|
||||
'0xc46f23bfe0653cac1e97856ba6f31cc9efb436822e835bd6331789bfd574d0e5': 'PriceCached',
|
||||
'0xf1dfddc73b1fe570da41b81e839cbefa121a067726d2f770d17faa01d0146fb7': 'FallbackPriceUsed'
|
||||
};
|
||||
const SEL = {
|
||||
quoteWei: '0x7de85694', // quoteWei(uint32)
|
||||
memberId: '0x39106821', // memberId(address)
|
||||
members: '0x5f59bb40', // members(uint32)
|
||||
creditBalance: '0x3a1d5b5c', // creditBalance(uint32,uint8)
|
||||
products: '0xbf712fd6', // products(uint32)
|
||||
productCount: '0xe0f6ef87', // productCount()
|
||||
memberCount: '0x11aee380' // memberCount()
|
||||
};
|
||||
|
||||
const CHUNK = 9000;
|
||||
const POLL_MS = 30000;
|
||||
const KEEP_EVENTS = 600;
|
||||
|
||||
let cfg = null;
|
||||
let state = null;
|
||||
let busy = false;
|
||||
let onEvent = null;
|
||||
|
||||
function getConfig() {
|
||||
if (!cfg) {
|
||||
let saved = {};
|
||||
try { saved = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch (e) {}
|
||||
cfg = Object.assign({
|
||||
// Amoy rehearsal defaults; mainnet flips these in the volume config
|
||||
contract: '0x07786E664AAfc0641eEB5297766935763dbA2288',
|
||||
chainId: 80002,
|
||||
chainName: 'Polygon Amoy (testnet rehearsal)',
|
||||
explorer: 'https://amoy.polygonscan.com',
|
||||
rpcs: ['https://polygon-amoy-bor-rpc.publicnode.com', 'https://polygon-amoy.drpc.org'],
|
||||
deployBlock: 46717266 // feed deploy block on Amoy (exact, from broadcast receipts)
|
||||
}, saved);
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
function reloadConfig() { cfg = null; return getConfig(); }
|
||||
|
||||
// ---- JSON-RPC with fallback rotation ----
|
||||
let rpcIdx = 0;
|
||||
function rpcOnce(url, method, params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });
|
||||
const u = new URL(url);
|
||||
const req = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 15000 },
|
||||
res => { let d = ''; res.on('data', c => d += c); res.on('end', () => {
|
||||
try { const j = JSON.parse(d); if (j.error) reject(new Error(j.error.message)); else resolve(j.result); }
|
||||
catch (e) { reject(e); } }); });
|
||||
req.on('error', reject); req.on('timeout', () => { req.destroy(new Error('rpc timeout')); });
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
async function rpc(method, params) {
|
||||
const urls = getConfig().rpcs;
|
||||
let last;
|
||||
// 3 rounds over the RPC pool with backoff — the public Amoy nodes routinely
|
||||
// return transient "Temporary internal error. Please retry" on eth_call, and a
|
||||
// single miss was silently surfacing as 0 credits / chainReadError on the
|
||||
// dashboard. Retry so a real read isn't lost to a momentary node hiccup.
|
||||
for (let round = 0; round < 3; round++) {
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
const url = urls[(rpcIdx + i) % urls.length];
|
||||
try { const r = await rpcOnce(url, method, params); rpcIdx = (rpcIdx + i) % urls.length; return r; }
|
||||
catch (e) { last = e; }
|
||||
}
|
||||
if (round < 2) await new Promise(r => setTimeout(r, 400 * (round + 1)));
|
||||
}
|
||||
throw last || new Error('all RPCs failed');
|
||||
}
|
||||
|
||||
// ---- ABI helpers ----
|
||||
const strip = h => (h || '').replace(/^0x/, '');
|
||||
const word = (data, i) => strip(data).slice(i * 64, i * 64 + 64);
|
||||
const toBig = h => BigInt('0x' + (strip(h) || '0'));
|
||||
const toNum = h => Number(toBig(h));
|
||||
const toAddr = h => '0x' + strip(h).slice(-40);
|
||||
const pad = (v, bits) => BigInt(v).toString(16).padStart(64, '0');
|
||||
function decodeString(data, wordIdx) {
|
||||
try {
|
||||
const off = toNum(word(data, wordIdx)) / 32;
|
||||
const len = toNum(word(data, off));
|
||||
return Buffer.from(strip(data).slice((off + 1) * 64, (off + 1) * 64 + len * 2), 'hex').toString('utf8');
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
async function call(sel, args) {
|
||||
const data = sel + (args || []).map(a => pad(a)).join('');
|
||||
return rpc('eth_call', [{ to: getConfig().contract, data }, 'latest']);
|
||||
}
|
||||
|
||||
// ---- reads ----
|
||||
async function memberIdByAccount(addr) {
|
||||
const r = await call(SEL.memberId, [BigInt(addr)]);
|
||||
return toNum(word(r, 0));
|
||||
}
|
||||
async function memberCount() { return toNum(word(await call(SEL.memberCount), 0)); }
|
||||
async function productCount() { return toNum(word(await call(SEL.productCount), 0)); }
|
||||
async function member(id) {
|
||||
const r = await call(SEL.members, [id]);
|
||||
return { account: toAddr(word(r, 0)), sponsorId: toNum(word(r, 1)), buyerCount: toNum(word(r, 2)),
|
||||
activated: toNum(word(r, 3)) === 1, countedAsBuyer: toNum(word(r, 4)) === 1 };
|
||||
}
|
||||
async function product(id) {
|
||||
const r = await call(SEL.products, [id]);
|
||||
return { id, priceCents: toNum(word(r, 0)), creditType: toNum(word(r, 1)),
|
||||
creditAmount: Number(toBig(word(r, 2))), active: toNum(word(r, 3)) === 1, exists: toNum(word(r, 4)) === 1 };
|
||||
}
|
||||
async function quoteWei(id) { try { return toBig(word(await call(SEL.quoteWei, [id]), 0)); } catch (e) { return null; } }
|
||||
async function creditBalance(id, type) { return Number(toBig(word(await call(SEL.creditBalance, [id, type || 0]), 0))); }
|
||||
async function catalog() {
|
||||
const n = await productCount();
|
||||
const out = [];
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const p = await product(i);
|
||||
if (!p.exists || !p.active) continue;
|
||||
const q = await quoteWei(i);
|
||||
out.push(Object.assign(p, { costWei: q === null ? null : q.toString() }));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- event decode ----
|
||||
function decodeLog(log) {
|
||||
const name = TOPICS[log.topics[0]];
|
||||
if (!name) return null;
|
||||
const t = i => log.topics[i];
|
||||
const d = log.data;
|
||||
const base = { type: name, block: parseInt(log.blockNumber, 16), tx: log.transactionHash, li: parseInt(log.logIndex, 16) };
|
||||
switch (name) {
|
||||
case 'MemberActivated': return Object.assign(base, { id: toNum(t(1)), account: toAddr(t(2)), sponsorId: toNum(word(d, 0)) });
|
||||
case 'Purchase': return Object.assign(base, { buyerId: toNum(t(1)), productId: toNum(t(2)),
|
||||
paidWei: toBig(word(d, 0)).toString(), priceCents: toNum(word(d, 1)), creditType: toNum(word(d, 2)), creditAmount: Number(toBig(word(d, 3))) });
|
||||
case 'TierPaid': return Object.assign(base, { buyerId: toNum(t(1)), recipientId: toNum(t(2)),
|
||||
tier: toNum(word(d, 0)), amountWei: toBig(word(d, 1)).toString(), hops: toNum(word(d, 2)) });
|
||||
case 'PassedUp': return Object.assign(base, { buyerId: toNum(t(1)), tier: toNum(word(d, 0)), skippedId: toNum(word(d, 1)), reason: decodeString(d, 2) });
|
||||
case 'AdminPaid': return Object.assign(base, { buyerId: toNum(t(1)), amountWei: toBig(word(d, 0)).toString() });
|
||||
case 'BuyerCounted': return Object.assign(base, { sponsorId: toNum(t(1)), newBuyerId: toNum(t(2)), newCount: toNum(word(d, 0)) });
|
||||
case 'CreditsConsumed': return Object.assign(base, { memberId: toNum(t(1)), creditType: toNum(word(d, 0)), amount: Number(toBig(word(d, 1))), ref: word(d, 2) });
|
||||
case 'AwardPaid': return Object.assign(base, { from: toAddr(t(1)), toId: toNum(t(2)), amountWei: toBig(word(d, 0)).toString() });
|
||||
default: return base; // catalog/oracle events: type + tx is enough for the feed
|
||||
}
|
||||
}
|
||||
|
||||
// ---- persistent live tail ----
|
||||
function loadState() {
|
||||
try { state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch (e) { state = null; }
|
||||
if (!state || state.v !== 1) state = { v: 1, lastBlock: getConfig().deployBlock - 1, events: [] };
|
||||
if (!state.totals) { for (const ev of state.events) tally(ev); saveState(); } // one-time backfill
|
||||
}
|
||||
function saveState() {
|
||||
try {
|
||||
const tmp = STATE_FILE + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(state));
|
||||
fs.renameSync(tmp, STATE_FILE);
|
||||
} catch (e) { console.error('chain state save failed', e.message); }
|
||||
}
|
||||
// Most-synced tip across the RPC pool. Public load-balanced nodes (publicnode)
|
||||
// sometimes answer eth_blockNumber from a replica lagging thousands of blocks
|
||||
// behind — which stalls the scan (latest < lastBlock) and, worse, could let a
|
||||
// getLogs from a lagging node skip freshly-mined events for good. Take the MAX
|
||||
// height and remember which node reported it, so we scan against a node we know
|
||||
// is synced to that height.
|
||||
async function bestTip() {
|
||||
const urls = getConfig().rpcs;
|
||||
let best = { block: 0, url: urls[0] };
|
||||
await Promise.all(urls.map(async u => {
|
||||
try { const b = parseInt(await rpcOnce(u, 'eth_blockNumber', []), 16);
|
||||
if (b > best.block) best = { block: b, url: u }; } catch (e) {}
|
||||
}));
|
||||
return best;
|
||||
}
|
||||
async function tail() {
|
||||
if (busy) return; busy = true;
|
||||
try {
|
||||
const tip = await bestTip();
|
||||
const latest = tip.block;
|
||||
while (state.lastBlock < latest) {
|
||||
const from = state.lastBlock + 1;
|
||||
const to = Math.min(from + CHUNK - 1, latest);
|
||||
// scan against the node we confirmed is synced to `latest`; fall back to
|
||||
// the rotating pool only if that specific node errors on this range.
|
||||
let logs;
|
||||
try { logs = await rpcOnce(tip.url, 'eth_getLogs', [{ address: getConfig().contract,
|
||||
fromBlock: '0x' + from.toString(16), toBlock: '0x' + to.toString(16) }]); }
|
||||
catch (e) { logs = await rpc('eth_getLogs', [{ address: getConfig().contract,
|
||||
fromBlock: '0x' + from.toString(16), toBlock: '0x' + to.toString(16) }]); }
|
||||
for (const lg of logs) {
|
||||
const ev = decodeLog(lg);
|
||||
if (!ev) continue;
|
||||
ev.ts = Date.now(); // indexed-at time: powers honest time-series charts
|
||||
state.events.push(ev);
|
||||
tally(ev);
|
||||
if (onEvent) { try { onEvent(ev); } catch (e) { console.error('chain onEvent', e.message); } }
|
||||
}
|
||||
if (state.events.length > KEEP_EVENTS) state.events = state.events.slice(-KEEP_EVENTS);
|
||||
state.lastBlock = to;
|
||||
}
|
||||
saveState();
|
||||
} catch (e) { console.error('chain tail', e.message); }
|
||||
busy = false;
|
||||
}
|
||||
function recentEvents(n) { return state ? state.events.slice(-(n || 100)).reverse() : []; }
|
||||
|
||||
// running totals for the public counters — every number provable on-chain
|
||||
function tally(ev) {
|
||||
if (!state.totals) state.totals = { purchases: 0, paidInWei: '0', payouts: 0, payoutWei: '0', activations: 0 };
|
||||
const t = state.totals;
|
||||
if (ev.type === 'Purchase') { t.purchases += 1; t.paidInWei = (BigInt(t.paidInWei) + BigInt(ev.paidWei)).toString(); }
|
||||
if (ev.type === 'TierPaid') { t.payouts += 1; t.payoutWei = (BigInt(t.payoutWei) + BigInt(ev.amountWei)).toString(); }
|
||||
if (ev.type === 'AwardPaid') { t.payouts += 1; t.payoutWei = (BigInt(t.payoutWei) + BigInt(ev.amountWei)).toString(); }
|
||||
if (ev.type === 'MemberActivated') t.activations += 1;
|
||||
}
|
||||
function totals() {
|
||||
return state && state.totals ? state.totals : { purchases: 0, paidInWei: '0', payouts: 0, payoutWei: '0', activations: 0 };
|
||||
}
|
||||
|
||||
function init(opts) {
|
||||
if (opts && opts.onEvent) onEvent = opts.onEvent;
|
||||
getConfig(); loadState();
|
||||
tail();
|
||||
setInterval(tail, POLL_MS);
|
||||
}
|
||||
|
||||
// Recommended EIP-1559 fees straight from the network. Polygon Amoy's Bor nodes
|
||||
// enforce a ~25 gwei minimum priority fee, but wallets (notably MetaMask) apply
|
||||
// a stale low estimate and get the raw tx rejected ("gas tip below minimum").
|
||||
// We hand the wallet correct fees so the tx clears the floor: a priority tip at
|
||||
// or above the network suggestion (floored at 30 gwei for headroom) and a
|
||||
// maxFee that covers 2x base + tip so MetaMask never flags "max fee too low".
|
||||
async function suggestedFees() {
|
||||
let tip = 0n, base = 0n;
|
||||
try { tip = BigInt(await rpc('eth_maxPriorityFeePerGas', [])); } catch (e) {}
|
||||
try { const blk = await rpc('eth_getBlockByNumber', ['latest', false]); base = BigInt((blk && blk.baseFeePerGas) || '0x0'); } catch (e) {}
|
||||
const MIN_TIP = 30000000000n; // 30 gwei — safely over Amoy's ~25 gwei floor
|
||||
const priority = tip > MIN_TIP ? tip : MIN_TIP;
|
||||
const maxFee = base * 2n + priority;
|
||||
return { maxPriorityFeePerGas: '0x' + priority.toString(16), maxFeePerGas: '0x' + maxFee.toString(16) };
|
||||
}
|
||||
|
||||
module.exports = { init, getConfig, reloadConfig, memberIdByAccount, memberCount, member,
|
||||
product, productCount, quoteWei, creditBalance, catalog, recentEvents, totals, rpc, decodeLog, suggestedFees };
|
||||
@@ -0,0 +1,168 @@
|
||||
// 24/7 site assistant (RM Circle pattern): canned answers first for the
|
||||
// questions everyone asks, OpenRouter AI for everything else, hard honesty
|
||||
// rules baked into the system prompt.
|
||||
//
|
||||
// HOUSE RULE: whenever a site feature changes, update BOTH the CANNED
|
||||
// answers and FACTS below in the same commit.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
|
||||
let DATA_DIR = null;
|
||||
let chain = null;
|
||||
const MODEL = process.env.OPENROUTER_MODEL || 'deepseek/deepseek-v4-flash:nitro';
|
||||
|
||||
function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; }
|
||||
function key() {
|
||||
if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY.trim();
|
||||
try { return fs.readFileSync(path.join(DATA_DIR, 'openrouter.key'), 'utf8').trim(); } catch (e) { return ''; }
|
||||
}
|
||||
function enabled() { return !!key(); }
|
||||
|
||||
// ---- canned answers: instant, free, always in voice ----
|
||||
const CANNED = [
|
||||
{ re: /(pyramid|ponzi|scam|scheme)/i,
|
||||
a: 'Fair question. A pyramid pays for recruiting with no product; here every payment is for real ad delivery, bought by real advertisers, and the whole money flow is public. Check the code and every payout yourself at https://linkspin-test.saasy.top/contract and https://linkspin-test.saasy.top/ledger.' },
|
||||
{ re: /(withdraw|cash ?out|payout request|minimum payout)/i,
|
||||
a: 'There are no withdrawals here, ever. The contract pays your share straight to your own wallet in the same transaction as the purchase. Nothing to request, nothing to wait on.' },
|
||||
{ re: /(how (much|do i) earn|commission|percent|split)/i,
|
||||
a: 'Every package splits the same way: 50 percent to the direct sponsor, 20 percent to level 2, 10 percent to level 3, 20 percent to the platform. Those numbers are constants in the contract and cannot be changed. Play with scenarios on the home page calculator. No income is promised; nobody earns unless real ad buying happens.' },
|
||||
{ re: /(balance (went|going|keeps going) down|credits? (disappear|missing|gone|dropping|went down|ticking)|why (is|did) my (balance|credits)|in campaigns|set aside|reserved credits|available to spend)/i,
|
||||
a: 'One rule: your balance is what is NOT committed to a live campaign. When you start a campaign its whole budget is set aside right then (earned credits first, then purchased), so your Available number drops once and then sits still while the ad runs. The budget spends down inside that campaign in Members > Campaigns, where you can see delivered views, network views and what is left. The dashboard shows Purchased available, Earned available and In live campaigns as three separate numbers; a paused campaign keeps its unspent budget set aside so it can resume. Nothing is lost when a balance reads low while a campaign is live. Purchased credits live on the blockchain; if we ever refund or credit purchased money it shows under Purchased as "credited to you" and spends like a purchase, login ads included. Earned credits fund every format except login ads.' },
|
||||
{ re: /(qualif|unlock level|level 2|level 3|pass.?up)/i,
|
||||
a: 'Two different things are earned here. CREDITS: every member, free ones included, earns ad credits by viewing ads, videos, visits and the daily sign-in; credits buy your own ads and are never withdrawn. POL: real crypto paid to your wallet when your referrals buy packages, once you are activated: the $20 starter package plus payouts switched on from your wallet. Level 1 pays 50 percent of the packages your direct referrals buy, from the day you are activated. Bring 2 buyers of $20 or more and level 2 unlocks; 5 unlock level 3. When a level is not qualified, its share climbs the sponsor line, checking up to 25 positions, and pays the first qualified person. Qualification never expires and cannot be bought. Qualified Start: you may link extra wallets of your own as positions under your account (Buy packages > Qualified Start); each one that buys a $20+ package counts as a qualifying buyer, its credits pool with yours, and 50% of its purchase comes back to your main wallet. Your own money, your own wallets, a faster start, never an income promise.' },
|
||||
{ re: /(free member|without (a )?wallet|do i (have to|need to) buy|earn (in )?credits|credits (vs|versus|or) pol|earn pol|get paid in)/i,
|
||||
a: 'Free members earn ad credits: view ads, watch videos, make verified visits, read inbox ads, sign in daily. Credits buy your own ads and are never cashed out. POL, the real crypto, is paid to your wallet by the contract when your referrals buy packages, once you are activated: the $20 starter package (2,000 credits to advertise with) and payouts switched on from the Wallet tab. Your own $20 purchase also makes you count as a qualifying buyer for your sponsor, and two or five such buyers in your own line open levels 2 and 3 for you.' },
|
||||
{ re: /(need (a )?wallet|crypto experience|metamask|how (do i|to) join|sign ?up|register)/i,
|
||||
a: 'Join free with just your email at https://linkspin-test.saasy.top/my, no wallet and no password needed. Your wallet only comes out when you buy a package or switch on payouts, and the site walks you through it.' },
|
||||
{ re: /(referral link|invite link|share link|refer)/i,
|
||||
a: 'You get your share link the moment you sign in, free members included. Open Members > Promo tools: the "Your links" block at the top lists your invite link plus five angle versions (Instant, Ad spend, Free, Ledger, Two), each with a note on who it is for and Copy and Share buttons. They are the same link with a code on the end, so every join and purchase credits you; only the first page the person sees changes. One tip: switch on payouts (one free wallet step in Members) before your people start buying, because the contract locks each buyer to their sponsor at their first purchase.' },
|
||||
{ re: /(promo tools?|social posts?|email swipes?|banners?|text a friend|objection|share buttons?)/i,
|
||||
a: 'Promo tools (Members > Promo tools) has pill menus for each kit: Social posts (X, Facebook, LinkedIn, Telegram or WhatsApp, with post and share buttons), Text a friend (SMS-sized messages with Text it, WhatsApp, Telegram and Copy), Email swipes (short, standard, long, follow-up), Banners (every standard ad size plus square, story and Telegram sizes, download or copy URL), your Banner wall link, and an Objection handling bank with the truth plus a ready-to-send reply. Everything is personalized with your invite link.' },
|
||||
{ re: /(drain (my|your) wallet|stop and go back|trust wallet.*(warn|block|red)|wallet (warning|blocked))/i,
|
||||
a: 'That red "this transaction will drain your wallet" screen is the wallet\'s own safety rule, not a problem with the purchase: Trust Wallet blocks any transaction that spends most of the POL in the wallet. Ways through: buy a smaller package first, add some POL so the purchase is well under half the balance, or connect a different wallet (MetaMask, Phantom, SafePal have no such block). Extra POL always stays yours. If the next try says WalletConnect disconnected, open Wallet, tap Disconnect, then Connect again. Phantom, SafePal and MetaMask do not have the hard block.' },
|
||||
{ re: /(wall (page|slots?|positions?)|banner wall|my wall|three (ads|slots) on (my|the) wall)/i,
|
||||
a: 'Your wall (linkspin-test.saasy.top/wall/yourname) shows three ads. Position 1 is always your line banner. Positions 2 and 3 show your upline (or LinkSpin) until you earn them: 2 qualifying buyers ($20 or more) make position 2 yours, 5 make position 3 yours, so a fully qualified member owns the whole page with their own links and offers. Set them in Profile > Your wall; an unlocked slot you leave empty keeps showing your upline until you fill it.' },
|
||||
{ re: /(solo ad|inbox ad|inbox)/i,
|
||||
a: 'Solo ads are full-message ads delivered straight into member inboxes on-site. Compose one under Campaigns (pick "Solo ad"): subject line, a rich-text message with a real editor (bold, headings, lists, links), an attached image or video if you want one, and a call-to-action button with your own label. You pay 5 credits per guaranteed delivery, 10 deliveries minimum. On the reading side, your Inbox section collects solos from other members — give one a real read (10 seconds on the open message) and claim 2 credits, up to 5 rewarded reads a day. You never receive your own solo.' },
|
||||
{ re: /(wrong sponsor|who is my sponsor|which sponsor|sponsor (cookie|link|credit)|clicked (someone|another|two).*link|last (link|click)|first (link|click|touch)|change (my )?sponsor)/i,
|
||||
a: 'The invite link you opened most recently sets your sponsor, and the join page shows it: "Personal invitation from @name". It locks the moment you create your account, so opening someone else\'s link afterwards changes nothing, and your first purchase binds you to that same sponsor on the contract. If you have not joined yet and the page shows the wrong name, open the right person\'s link and it updates. If you already joined under the wrong sponsor by mistake, message support before your first purchase and the admin can move you.' },
|
||||
{ re: /(chat|message (my )?(sponsor|upline|team|downline)|talk to (my )?sponsor|contact (my )?sponsor|ask (my )?sponsor|reach (my )?sponsor)/i,
|
||||
a: 'Yes, Members has a two-way Sponsor Chat. You can message your direct sponsor for help (from Overview or the chat button), and sponsors can message anyone in their line. If they are online you chat live; if not, your message waits in their on-site inbox and they get an email. Sponsors can set themselves unavailable in Profile (you can still leave a note, they just reply later) and mute a member if needed. It is separate from the once-a-day team broadcast.' },
|
||||
{ re: /((view|watch|see).{0,12}ads?|earn.{0,12}credits?|daily (set|ads|views))/i,
|
||||
a: 'In the Earn credits section of Members, each ad in the daily set opens full screen in its own tab, showing the advertiser\'s real site. A countdown runs while you watch (it pauses if you leave the tab), then you pass a quick click-the-icon check and the view counts. Finish the set, claim your daily credits, and spend them on your own banner or text campaigns. You never see your own ads, and viewer rewards are credits, never cash.' },
|
||||
{ re: /(credit|impression|cpm|what do i get|what am i buying)/i,
|
||||
a: 'Packages mint ad credits on-chain, and every new member also gets a small batch of welcome credits just for joining. One credit is one cent of ad delivery: banners, text ads, full-screen login ads, and solo ads across the network, managed from the campaign manager in Members. Only your campaigns can spend your credits.' },
|
||||
{ re: /(price|cost|package|how much is)/i,
|
||||
a: 'Packages run 5 to 250 dollars, priced in dollars and settled in POL at the live rate when you buy. The full ladder with live pricing is on the home page. Packages of $20 or more count toward qualification.' },
|
||||
{ re: /(which|what) (crypto|coin|token|currency|chain|network)|paid in what|get paid in|supported (crypto|coins|wallets)|do you (support|accept)/i,
|
||||
a: 'One coin, one network: POL, the native coin of Polygon. You pay for packages in POL and every payout arrives as POL in your own Polygon wallet, in the same transaction. No other coins, tokens or chains are used. Any Polygon wallet works (MetaMask, SafePal, Phantom, Coinbase Wallet), and you can buy POL with a card from Buy packages if you have never held crypto.' },
|
||||
{ re: /(which wallet|what wallet|best wallet|recommended wallet|set ?up (a )?wallet|buy (pol|crypto|polygon)|get (pol|crypto)|moonpay|card to crypto|no crypto|never (had|held|owned) crypto)/i,
|
||||
a: 'Preferred wallets: MetaMask (recommended, and the one for Qualified Start extra accounts), Phantom (turn Polygon on first: Settings, Active Networks), SafePal or Coinbase Wallet. Trust Wallet works but blocks a purchase that spends most of the POL in it, so keep about double the package cost there. To buy POL with a card: connect your wallet, open Buy packages, tap "Buy POL with a card". MoonPay opens with POL on Polygon and your own address filled in; pay by card, Apple Pay or Google Pay (first time needs an ID check, minimum order around $30), the POL lands in your wallet in minutes, then buy the package. The full step-by-step guide is in Training: Wallets and buying POL (https://linkspin-test.saasy.top/wallets, members only).' },
|
||||
{ re: /(safe|trust|rug|run away|company disappear)/i,
|
||||
a: 'The money never touches us. An immutable contract splits every purchase in the same transaction, holds zero balance, and has no pause switch or upgrade path. Read the plain-language review and the verified source at https://linkspin-test.saasy.top/contract.' }
|
||||
];
|
||||
|
||||
// ---- facts for the AI fallback ----
|
||||
function systemPrompt() {
|
||||
return `You are the assistant on LinkSpin (https://linkspin-test.saasy.top), a membership advertising platform.
|
||||
|
||||
FACTS:
|
||||
- Free to join with email only (6-digit code sign-in, no passwords). Wallet appears only at purchase or payout activation. Every new member gets a small welcome batch of ad credits — unlocked by the WELCOME TOUR on first sign-in: they visit their upline's line-banner sites (up to 3, 10 seconds each — the same 3 levels the contract pays), then claim the credits. Members with no upline banners get the credits instantly.
|
||||
- SPONSOR ATTRIBUTION: last touch. The invite link opened most recently sets the sponsor (30-day cookie), shown on the join page and again at the code step ("Joining under @name"). It locks when the account is created; the contract binds the buyer to that sponsor at their first purchase. Wrong sponsor before joining: open the right link. Already joined wrong: admin can move the account before the first purchase.
|
||||
- YOUR LINKS (top of Members > Promo tools): the plain invite link plus five angle links (?v=instant paid-in-seconds, ?v=adspend you-buy-ads-anyway, ?v=free costs-nothing-to-try, ?v=ledger no-back-office, ?v=two two-buyers-open-level-two), each with a who-it-is-for note, Copy and Share. Same credit either way; only the first page differs. Link stats (Coaching pane) shows views/joins/buyers per angle.
|
||||
- PROMO TOOLS (Members > Promo tools, pill menu): Social posts for X/Facebook/LinkedIn/Telegram-WhatsApp with post/share buttons; Text a friend (5 SMS-sized messages with Text it / WhatsApp / Telegram / Copy); Email swipes (short, standard, long, follow-up); Banners in every standard ad size plus 1080x1080, 1080x1920 and 1280x720 (download or copy URL); the member's Banner wall link; an Objection handling bank (truth + ready-to-send reply); a Videos tab (hook videos in production). Every piece carries the member's invite link; angle links add ?v=instant|adspend|free|ledger. Members who want copy in their own voice can use mybrandedvoice.com.
|
||||
- INVITE PAGES: a member's link linkspin-test.saasy.top/join/<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.
|
||||
- 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.
|
||||
- BLOG (public, linkspin-test.saasy.top/blog): Marty's coaching and teaching articles on building a line, advertising that pays, and daily habits; each article has its own page and can be shared; RSS at /blog/feed.xml. Members who want to write their own articles: not offered today.
|
||||
- ACHIEVEMENT BADGES ON TELEGRAM (2026-09-13): when a member unlocks Spark/Surge/Circuit/Nexus, their personalised badge image (username on the ribbon) is posted automatically to the team's Telegram payments topic and the main group, once per badge; members cannot trigger posts themselves (the 'Post to Telegram' button is admin-only); 'Share' opens a picker (X, Facebook, Telegram, WhatsApp, LinkedIn, Massifly (copies the post and opens the feed composer), the phone's own share menu, copy link, save image) that shares the member's public badge page linkspin-test.saasy.top/b/<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@linkspin-test.saasy.top with an opt-out link; the log shows sent/opted out/failed.
|
||||
- LINKED POSITIONS AND BADGES (2026-09-14): qualifying buyers on a member's linked positions count toward their achievement badges (Spark/Surge/Circuit/Nexus) and light their chip on their sponsor's line, and a badge once earned never regresses. CONTRACT RULE: levels 2 and 3 are paid to the MAIN wallet only when that wallet's own count of qualifying buyers reaches 2 and 5; buyers registered under a linked position count for that position, not the main wallet. Qualified Start positions buy UNDER the main wallet, so those buys raise the main count; a member whose old wallet was swapped out (its buyers stay with the old position) needs new $20+ buyers under the current main wallet before level 2 pays it. The contract still pays levels 2 and 3 to each position based on that position's OWN qualifying buyers, so the Qualifying buyers tile shows the main position's count with the linked positions' count underneath.
|
||||
- CREDITS VS POL (2026-09-15, Marty's stance): EVERY member earns CREDITS (viewing, videos, visits, inbox, sign-in bonus, badge bonuses); credits are ad delivery, not money. POL is paid by the contract to a member's wallet when referrals buy packages. ALWAYS describe activation as 'the $20 starter package plus payouts switched on' and lead every earning answer with it; do NOT volunteer that level 1 can pay without a purchase. If someone asks point-blank whether a purchase is required for level-1 payouts, answer truthfully in one sentence: the contract pays level 1 to any member whose payouts are switched on, and the $20 package is what makes them a qualifying buyer for their sponsor, funds their own ads, and unlocks badges, tools, the holding tank and wall positions. A $20+ purchase by a referral makes that referral a qualifying buyer for their sponsor; 2 and 5 open levels 2 and 3. Never claim the payout itself is gated by a purchase.
|
||||
- PIPELINE (coming soon, built 2026-09-15; opens when the site setting pipelineMode is on): a follow-up board on the dashboard, tab 'Pipeline' between My line and Buy packages. Columns: Talking to, Joined, Wallet linked, Payouts on, Bought, Building, Later. Prospects (from the My line prospect list) and directs are placed automatically from what they have actually done (coaching rung, on-chain buys); nobody drags cards. The sponsor adds a note, a follow-up date and a tag (hot, later, no response, not interested); a 'Follow up today' strip lists due cards; stalled cards (quiet 3+ days) are flagged; each card carries the message for its exact stage with 'Open chat with this message' (member chat) or copy. Until it opens, the tab shows a coming-soon card with the roadmap date.
|
||||
- PAYMENT + MISSED-PAYMENT NOTICES (2026-09-15): every payout that lands (who bought, level, share in POL and dollars, transaction link) and every missed payout is delivered BOTH by email and as an on-site inbox message from the company account, shown in the login pop-up and the Messages card, so it is waiting when the member signs in. MISSED-PAYOUT detail: when a level-2 or level-3 share passes a member by because that level is not open on their account, the member gets an email the same minute: who bought, the POL and dollar amount they missed, how many qualifying buyers they have versus the 2 or 5 needed, and the two ways to close the gap (bring buyers, or Qualified Start). Qualified members whose wallet rejected a transfer get a different email telling them to link a regular wallet.
|
||||
- LAUNCH WEEK SWIPES (2026-09-15): the founding-week checklist page (/launch) ends with four promoter emails members send to their OWN lists, one a day toward Monday's opening, with the member's invite link and the FOUNDER code (500 credits for anyone who joins before Mon 2026-09-21 9 AM Central) filled in, each with a Copy button. These are swipes for members, not emails the site sends.
|
||||
- LOGIN ADS CHARGE ONLY ON DAYS SHOWN (2026-09-15): the login ad daily fee (100 credits) is charged only for days the ad was actually shown at least once; with many login ads sharing the sign-ins, an ad that was not picked that day pays nothing. Featured links now count views (one per viewer per hour) as well as clicks.
|
||||
- SPONSOR HOLD (2026-09-14): if a member's account names a sponsor that cannot be paid right now (sponsor has not switched on payouts, or the chain lookup failed), the first purchase and the payouts activation are held with a message instead of silently crediting the company. Fix: the sponsor switches on payouts, or the member retries; the admin gets a Telegram alert.
|
||||
- NO-PAYOUT POSITIONS (2026-09-14): admin can list member ids (Settings > No-payout positions) whose wallets must never be paid again (e.g. a compromised key). Those positions stay linked for history and counting only: the buy-from picker disables them, a purchase from them is refused, and invite links that would place a new member under them (or under their upline chain) route to the fallback sponsor instead. The contract itself cannot change a member's wallet.
|
||||
- LINE AT A GLANCE, ALL LEVELS (2026-09-14): a gold chip on any level means that person made their $20+ buy (a qualifying buyer for their own sponsor); a small number on the chip is how many qualifying buyers of their own they have. My line rows say the same in words. Leaders use it to see who is one buyer short and encourage them.
|
||||
- MY LINE SPONSOR LINE (2026-09-14): every row on My line, all three levels, shows who sponsored that person ("sponsored by @name", or "you" for directs).
|
||||
- MY LINE ACTIVITY DROP-DOWN (2026-09-14): every row on My line, all three levels, has an Activity button that opens that person's activity: last seen, joined, stage and next step, ads viewed today and claim streak, campaigns active, link views in 30 days and joins, directs and qualifying buyers, wallet/payouts, badges, and a Working or Quiet verdict. Read-only, phone friendly.
|
||||
- GETTING STARTED CARD (2026-09-14): the top of every new member's Overview shows four steps (username, link wallet, switch on payouts, first package) with the current one explained and one button that opens the right tab and highlights the control (Wallet tab > Link wallet; then Activate payouts). Encouragement only: it can be hidden, and members who only want to view ads and earn credits are never blocked. It disappears once all four are done.
|
||||
- LEADERBOARD + REFERRAL CONTEST (2026-09-14): linkspin-test.saasy.top/leaderboard ranks members by ad packages sold to people they directly sponsor (dollar value, read from the chain; a member's own linked positions and second accounts never count). Periods: this week (Monday to Sunday, Central time), this month, all time. Weekly and monthly winners are recorded automatically at rollover, announced in Telegram, and the credit prizes set by the admin (a ladder: 1st 1,000 / 2nd 500 / 3rd 250 weekly, 5,000 / 2,500 / 1,000 monthly by default) are granted automatically to those positions; the prize text is shown on the page and on the Overview's Leaderboard card, which also shows the member's own rank. Prizes are credits or packages, never cash.
|
||||
- WHAT'S NEW / ROADMAP (2026-09-14): linkspin-test.saasy.top/whats-new lists release notes (what shipped, dated, tagged new/improved/fixed) and the roadmap (planned / building, with rough ETAs). The Overview has a "What's new" card with the latest three notes and what is being built; a dot marks notes since the member's last look. Written by the admin in Admin > Releases.
|
||||
- HOLDING TANK ALERTS: when new members land in the tank, a note at the top of every member's Overview names them (usernames) and a post goes to the team's Telegram payments topic; adopt from My line > Holding tank (your own $20 package required).
|
||||
- LEGACY WELCOME (former Faucet Wave / Tier One Ads members): they join through linkspin-test.saasy.top/from/faucetwave or linkspin-test.saasy.top/from/tieroneads and, if their email is on the legacy list, welcome-back credits are added automatically at signup (former advertisers 500, former earners 150; once per person; credits, not POL). They land in the holding tank like any member who joins without a sponsor.
|
||||
- PROMO CODES: partner site owners get a reusable code; a member redeems it on a join link (?promo=CODE) or in the Overview box "Have a promo code?" and receives free ad credits (amount set per code by the admin, one use per account; codes can cap uses or expire). Credits, not POL.
|
||||
- DORMANT-LEAD RESCUE: a FREE referral (no wallet, no purchase) with no message from their sponsor for 10 days triggers a warning email + dashboard flag to the sponsor ("unreached, tank in N days"); at 14 days (warning at least 4 days old) the lead moves to the holding tank and the sponsor is told. Sponsor resets the clock with a chat, a Nudge, or the "Contacted them" button (for phone/text contact). Leads whose sponsor link resolves to nobody go to the tank after a day. Nothing on-chain moves; anyone bound by a purchase never moves.
|
||||
- PIF (pay it forward) button: on a free direct or an adopted member who has linked a wallet, the sponsor taps PIF, enters an amount (suggested: the $20 package plus fees), and their OWN wallet app opens with the member's address prefilled; the POL goes wallet to wallet. The site never touches the funds; it only logs the transaction and tells the recipient with a Polygonscan link. The gift is theirs; nothing forces a purchase.
|
||||
- FOUNDING WEEK / PRE-LAUNCH (Training > Founding week checklist, /launch, members only): eight items read live from the account: username, wallet linked, payouts on, level 2 qualified (2 buyers of $20+, or Qualified Start with 2 linked positions), the leader play = level 3 (5 qualifying buyers, up to 5 linked positions; then buy from the main wallet), line banner, links + play chosen (self-marked), first two placed. Reason: unqualified levels pass up, so leaders qualify BEFORE their teams' teams buy. Countdown shows when admin sets launchAt. Never call the site 'pre-launch' publicly: it is live and paying.
|
||||
- COUNTRY TARGETING (2026-09-11): New campaign form has "Show to" tier checkboxes (Tier 1 = US, CA, GB, AU, NZ, IE, DE, FR, NL, SE, NO, DK, FI, CH, AT, BE by default; Tier 2 = rest of Western/Central Europe, JP, KR, SG, HK, TW, IL, Gulf, ZA, BR, MX, AR, CL, CO, etc.; Tier 3 = everyone else; lists editable in Admin > Settings geoTier1/geoTier2). Country comes from the viewer's IP (DB-IP lite). Applies to on-site delivery of every format; a narrowed banner/text ad is NOT sent to the partner network (which is worldwide). Unknown country never matches a narrowed campaign. Campaign rows show 'tier 1+2' chips and top viewer countries.
|
||||
- SCHEDULING (2026-09-11): any campaign except featured can take an optional start and end time (local time) in the New campaign form; solo ads label it "Send from" (inbox deliveries begin then). A scheduled campaign shows "scheduled" until it starts; at the end it shows "ended" and the unspent budget returns to Available. Banner/text scheduled campaigns join the partner network at their start time. There is NO dayparting (hours-of-day targeting) by design; the daily cap paces budgets. Each campaign row shows a small views-by-hour chart (on-site views, viewer's local time, last 7 days).
|
||||
- BALANCE RULE (members ask this a lot): a balance is what is NOT committed to a live campaign. Starting a campaign sets aside its whole budget at once (earned pool first, then purchased), so Available drops once and stays still while ads serve; the budget spends down inside Members > Campaigns. Dashboard shows Purchased available, Earned available, In live campaigns. A paused campaign keeps its unspent budget set aside so it can resume. A low balance with a live campaign is not lost credits. Refunds/comps of purchased money are credited off-chain as purchased-grade credits: shown under Purchased as "credited to you", fund anything incl. login ads. Viewing-earned credits never fund login ads.
|
||||
- Members EARN credits by attention: in the Earn credits section of Members, each ad in the daily set opens FULL SCREEN in its own tab, showing the advertiser's real website. A countdown runs while you watch (it pauses if you leave the tab), then a quick human check (click the named icon) must be passed before the view counts. Finish the daily set, claim a small daily credit batch. Earned credits spend on banner and text campaigns; attention earns advertising, referrals earn money, and viewer rewards are never cash. Advertisers get real, verified visits to their site.
|
||||
- Onsite SOLO ADS are live: a solo ad is a full message (subject + up to 2000 characters of formatted text + your link) delivered into members' on-site Inbox (Members > Inbox). The composer in Campaigns > Solo ad has a rich-text editor (bold, headings, lists, links), lets you ATTACH one image (PNG/JPG/WebP/GIF, up to 3MB) or one video (MP4/WebM, up to 25MB), and adds a call-to-action button with a custom label that opens the target URL. Cost 5 credits per guaranteed delivery, minimum 10 deliveries (50 credits). Each member receives a given solo at most once, and never the sender's own. Readers earn 2 credits per real read (10-second dwell on the open message, up to 5 rewarded reads/day) — claimed right from the message. Compose one in Campaigns > Solo ad.
|
||||
- Campaign target URLs are checked the moment they are submitted: the page must be reachable and must ALLOW framing (no X-Frame-Options deny/sameorigin, no blocking CSP frame-ancestors), because surf views show the real site full screen. Frame-blocking or dead URLs are rejected with the exact reason; the fix is a landing page that allows framing. Login-ad targets skip the frame check (they are click-through only).
|
||||
- SPONSOR CHAT (Members): a two-way, one-to-one chat. A member can start a conversation with their DIRECT sponsor; a sponsor can message anyone in their downline. Presence-aware — when both are online it is live, otherwise a message waits in the recipient's on-site inbox and sends an email. Each member has an availability toggle in Profile (default on; turning it off stops live chats but people can still leave a note) and can mute a specific person. This is separate from the once-a-day sponsor BROADCAST to the whole downline.
|
||||
- Every purchase is split by an immutable smart contract in the same transaction: 50% direct sponsor, 20% level 2, 10% level 3, 20% platform. No withdrawals exist; money lands in members' own wallets instantly.
|
||||
- Qualification: level 1 open to all; 2 buyers of $20+ unlock level 2; 5 unlock level 3. Unqualified shares pass up the sponsor line, checking up to 25 positions, else the platform receives them. Qualification cannot be bought and never expires.
|
||||
- Every member gets a share link immediately (site code, /join/<code>); the contract locks a buyer to their sponsor at the buyer's FIRST purchase, so members should switch on payouts before their referrals buy.
|
||||
- Transparency: live ledger at /ledger streams every chain event, and EVERY transaction row carries a clickable "verify" link — to the public block explorer when the chain has one, or to the built-in transaction viewer at /tx/<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.
|
||||
|
||||
RULES:
|
||||
- 1 to 4 sentences, plain text, no markdown, include full URLs when pointing at a page.
|
||||
- NEVER promise or estimate income. This is an advertising service with a referral program, never an investment. Be honest about risk: crypto transactions are irreversible, nobody earns unless real ad purchases happen.
|
||||
- No em dashes. If you do not know, say so and point to /contract or the ledger.`;
|
||||
}
|
||||
|
||||
function askAI(message, historyText) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = JSON.stringify({
|
||||
model: MODEL,
|
||||
max_tokens: 300,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt() },
|
||||
...(historyText ? [{ role: 'user', content: 'Earlier context: ' + historyText.slice(0, 800) }] : []),
|
||||
{ role: 'user', content: String(message).slice(0, 600) }
|
||||
]
|
||||
});
|
||||
const req = https.request({ hostname: 'openrouter.ai', path: '/api/v1/chat/completions', method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + key(), 'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body) }, timeout: 30000 },
|
||||
res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(d).choices[0].message.content.trim()); }
|
||||
catch (e) { reject(new Error('openrouter ' + res.statusCode + ': ' + d.slice(0, 150))); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => req.destroy(new Error('openrouter timeout')));
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
async function answer(message, historyText) {
|
||||
const msg = String(message || '').trim();
|
||||
if (!msg) return { error: 'Say something first.' };
|
||||
for (const c of CANNED) if (c.re.test(msg)) return { reply: c.a, canned: true };
|
||||
if (!enabled()) return { reply: 'Good question. The quick answers I have cover packages, payouts, qualification, and joining; for anything deeper, the plain-language contract review at https://linkspin-test.saasy.top/contract covers most of it.' };
|
||||
try { return { reply: await askAI(msg, historyText) }; }
|
||||
catch (e) {
|
||||
console.error('chat AI failed', e.message);
|
||||
return { reply: 'I hit a snag answering that one. The contract review at https://linkspin-test.saasy.top/contract and the live ledger at https://linkspin-test.saasy.top/ledger cover most deep questions.' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { init, answer, enabled };
|
||||
@@ -0,0 +1,292 @@
|
||||
// Coaching layer: where each member sits on the ladder, what to say to them,
|
||||
// automatic nudges to stalled members, a weekly digest to their sponsor, a
|
||||
// prospect list per member, and per-angle link stats. Dual-mode store like the
|
||||
// other modules (MySQL when DATABASE_URL is set, JSON files on the volume otherwise).
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('./db');
|
||||
|
||||
let DATA_DIR = null, chain = null, accounts = null, mailer = null;
|
||||
const DAY = 86400000;
|
||||
|
||||
// ---- the ladder (same rungs the Overview stepper and achievements use) ----
|
||||
const RUNGS = [
|
||||
{ n: 0, key: 'joined', label: 'Joined', next: 'Link a wallet',
|
||||
say: 'Hey {{name}}, quick one: link your wallet on the Wallet tab (one free signature, it cannot move funds). That is what lets payouts reach you. Two minutes, and I am here if you get stuck.',
|
||||
mail: ['Your LinkSpin wallet is not linked yet', 'You joined LinkSpin but no wallet is linked yet, so nothing can pay you. Open the Wallet tab and press Connect and link wallet. One free signature, it cannot move funds. Then switch on payouts and you are set for good.'] },
|
||||
{ n: 1, key: 'wallet', label: 'Wallet linked', next: 'Switch on payouts',
|
||||
say: 'Wallet is linked, nice. Next is Switch on payouts (Wallet tab, one free transaction). Do it before anyone under you buys, so nothing passes you by.',
|
||||
mail: ['One free step left: switch on payouts', 'Your wallet is linked. One free transaction on the Wallet tab switches on payouts, and from then on every purchase in your line can pay you in the same transaction it happens. Do it before your people start buying: the contract locks each buyer to their sponsor at their first purchase.'] },
|
||||
{ n: 2, key: 'payouts', label: 'Payouts on', next: 'Buy a first package ($20+ counts)',
|
||||
say: 'You are set to earn. Fastest way to see it work: the $20 Activation package. It counts as a qualifying buy for your sponsor and gives you 2,000 credits to run your first ad.',
|
||||
mail: ['See a payout land: your first package', 'Payouts are on. The fastest way to see the whole thing work is the $20 Activation package on Buy packages: 2,000 credits for your own ads, and your sponsor gets paid the second it settles, on a public ledger you can open yourself.'] },
|
||||
{ n: 3, key: 'bought', label: 'Bought $20+', next: 'Share the link with one person',
|
||||
say: 'Everything is on. Now send your invite link to one person today. Promo tools has a ready-to-send message. Who is the first person you thought of?',
|
||||
mail: ['Send your link to one person today', 'Everything on your account is switched on. Nobody has joined your link yet. Open Promo tools, copy the ready-to-send message, and send it to one person today. One conversation a day is the whole job.'] },
|
||||
{ n: 4, key: 'first', label: 'First buyer', next: 'One more buyer opens level 2',
|
||||
say: 'First buyer, congrats. One more $20+ buyer opens level 2 for you. Want a shortcut? Qualified Start under Buy packages lets you be your own second buyer.',
|
||||
mail: ['One more buyer opens level 2', 'You have your first qualifying buyer. One more $20+ buyer opens level 2 (20% on every package your directs\' people buy). If you would rather not wait, Qualified Start on Buy packages lets you be your own second buyer with a wallet you own.'] },
|
||||
{ n: 5, key: 'level2', label: 'Level 2 open', next: 'Three more buyers open level 3',
|
||||
say: 'Level 2 is open. Three more qualifying buyers open level 3 and the Nexus badge. Which of your people is closest?',
|
||||
mail: ['Three more buyers open level 3', 'Level 2 is open on your account. Five qualifying buyers open level 3 and the Nexus badge, and every package on all three levels pays you. Which of your people is closest? Message them from My line.'] },
|
||||
{ n: 6, key: 'level3', label: 'Level 3 open', next: 'Coach your directs to their two',
|
||||
say: 'Fully qualified. Now the multiplier: teach your directs the same three steps. Their buyers are your level 2 and 3.',
|
||||
mail: null }
|
||||
];
|
||||
function rungFor(acct, mm) {
|
||||
if (!acct || !acct.address) return 0;
|
||||
if (!acct.memberId) return 1;
|
||||
if (!mm || !mm.countedAsBuyer) return 2;
|
||||
const bc = mm.buyerCount || 0;
|
||||
if (bc === 0) return 3;
|
||||
if (bc === 1) return 4;
|
||||
if (bc < 5) return 5;
|
||||
return 6;
|
||||
}
|
||||
const memberCache = new Map();
|
||||
async function member(id) {
|
||||
if (!id) return null;
|
||||
const c = memberCache.get(id);
|
||||
if (c && Date.now() - c.t < 60000) return c.v;
|
||||
let v = null; try { v = await chain.member(id); } catch (e) {}
|
||||
memberCache.set(id, { t: Date.now(), v });
|
||||
return v;
|
||||
}
|
||||
const STALL_DAYS = 3;
|
||||
async function describe(acct, now = Date.now()) {
|
||||
const mm = await member(acct.memberId);
|
||||
const rung = rungFor(acct, mm);
|
||||
const last = Math.max(acct.created || 0, acct.lastSeen || 0);
|
||||
const quietDays = Math.floor((now - last) / DAY);
|
||||
const stalled = rung < 6 && quietDays >= STALL_DAYS;
|
||||
const R = RUNGS[rung];
|
||||
return { rung, label: R.label, next: R.next, say: R.say, quietDays, stalled, buyerCount: mm ? mm.buyerCount : 0,
|
||||
counted: !!(mm && mm.countedAsBuyer), joined: acct.created, lastSeen: acct.lastSeen || 0, onchainSponsorId: mm ? mm.sponsorId : null };
|
||||
}
|
||||
// coaching view for a sponsor: every direct, ladder rung, stalled flag, what to say
|
||||
async function coachView(email) {
|
||||
const levels = await accounts.downline(email, 1);
|
||||
const directs = levels.length ? levels[0].members : [];
|
||||
const me = await accounts.byEmail(email);
|
||||
const myId = me ? (me.memberId || 0) : 0;
|
||||
const now = Date.now();
|
||||
const out = [];
|
||||
for (const d of directs) {
|
||||
const c = await describe(d, now);
|
||||
// bound: the contract pays whoever the member registered under. A direct who activated with
|
||||
// no sponsor (or a different one) is in this line on the site but pays this member nothing.
|
||||
c.bound = c.onchainSponsorId == null ? null : (myId > 0 && c.onchainSponsorId === myId);
|
||||
c.free = !d.memberId; // still free: can be released to the holding tank (pay it forward)
|
||||
c.address = (!d.memberId && d.address) ? d.address : null; // linked wallet of a free direct: the PIF gift target
|
||||
out.push(Object.assign({ email: d.email, name: d.username ? '@' + d.username : (d.memberId ? 'member #' + d.memberId : d.email.replace(/^(.).*(@.*)$/, '$1***$2')), memberId: d.memberId || 0 }, c));
|
||||
}
|
||||
// most actionable first: stalled lowest rung, then quiet days
|
||||
out.sort((a, b) => (Number(b.stalled) - Number(a.stalled)) || (a.rung - b.rung) || (b.quietDays - a.quietDays));
|
||||
return { directs: out, stalled: out.filter(x => x.stalled).length, rungs: RUNGS.map(r => ({ n: r.n, label: r.label, next: r.next })) };
|
||||
}
|
||||
|
||||
// ---- stores ----
|
||||
const J = {
|
||||
db: { v: 1, nudges: {}, digests: {}, prospects: [], nextId: 1, views: [] },
|
||||
FILE: () => path.join(DATA_DIR, 'coach.json'),
|
||||
load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} },
|
||||
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} },
|
||||
async lastNudge(email) { return this.db.nudges[email] || null; },
|
||||
async setNudge(email, rung, ts) { this.db.nudges[email] = { rung, ts }; this.save(); },
|
||||
async lastDigest(email) { return this.db.digests[email] || 0; },
|
||||
async setDigest(email, ts) { this.db.digests[email] = ts; this.save(); },
|
||||
async prospects(email) { return this.db.prospects.filter(p => p.owner === email).sort((a, b) => (a.nextTs || 9e15) - (b.nextTs || 9e15)); },
|
||||
async saveProspect(email, p) {
|
||||
if (p.id) { const cur = this.db.prospects.find(x => x.id === p.id && x.owner === email); if (!cur) return null; Object.assign(cur, p, { updated: Date.now() }); this.save(); return cur; }
|
||||
const row = Object.assign({}, p, { id: this.db.nextId++, owner: email, created: Date.now(), updated: Date.now() }); this.db.prospects.push(row); this.save(); return row;
|
||||
},
|
||||
async removeProspect(email, id) { const n = this.db.prospects.length; this.db.prospects = this.db.prospects.filter(x => !(x.id === Number(id) && x.owner === email)); this.save(); return n !== this.db.prospects.length; },
|
||||
async addView(token, angle, ts, ref) { this.db.views.push({ token, angle, ts, ref: ref || '' }); if (this.db.views.length > 50000) this.db.views = this.db.views.slice(-40000); this.save(); },
|
||||
async addClick(campaignId, src, ts) { this.db.clicks = this.db.clicks || []; this.db.clicks.push({ campaignId, src, ts }); if (this.db.clicks.length > 50000) this.db.clicks = this.db.clicks.slice(-40000); this.save(); },
|
||||
async clicksFor(ids) { return (this.db.clicks || []).filter(c => ids.includes(c.campaignId)); },
|
||||
async views(tokens, since) { return this.db.views.filter(v => tokens.includes(v.token) && v.ts >= since); },
|
||||
async viewsSince(since) { return this.db.views.filter(v => v.ts >= since); }
|
||||
};
|
||||
const D = {
|
||||
async lastNudge(email) { const r = await db.q('SELECT rung, ts FROM nudges WHERE email=?', [email]); return r[0] ? { rung: r[0].rung, ts: Number(r[0].ts) } : null; },
|
||||
async setNudge(email, rung, ts) { await db.q('INSERT INTO nudges (email,rung,ts) VALUES (?,?,?) ON DUPLICATE KEY UPDATE rung=VALUES(rung), ts=VALUES(ts)', [email, rung, ts]); },
|
||||
async lastDigest(email) { const r = await db.q('SELECT ts FROM digests WHERE email=?', [email]); return r[0] ? Number(r[0].ts) : 0; },
|
||||
async setDigest(email, ts) { await db.q('INSERT INTO digests (email,ts) VALUES (?,?) ON DUPLICATE KEY UPDATE ts=VALUES(ts)', [email, ts]); },
|
||||
async prospects(email) {
|
||||
const rows = await db.q('SELECT * FROM prospects WHERE owner_email=? ORDER BY COALESCE(next_ts, 9e15), created', [email]);
|
||||
return rows.map(r => ({ id: r.id, owner: r.owner_email, name: r.name, contact: r.contact, status: r.status, note: r.note, nextTs: r.next_ts ? Number(r.next_ts) : null, created: Number(r.created), updated: Number(r.updated) }));
|
||||
},
|
||||
async saveProspect(email, p) {
|
||||
if (p.id) {
|
||||
await db.q('UPDATE prospects SET name=?, contact=?, status=?, note=?, next_ts=?, updated=? WHERE id=? AND owner_email=?', [p.name, p.contact, p.status, p.note, p.nextTs || null, Date.now(), Number(p.id), email]);
|
||||
return (await this.prospects(email)).find(x => x.id === Number(p.id)) || null;
|
||||
}
|
||||
const r = await db.q('INSERT INTO prospects (owner_email,name,contact,status,note,next_ts,created,updated) VALUES (?,?,?,?,?,?,?,?)', [email, p.name, p.contact, p.status, p.note, p.nextTs || null, Date.now(), Date.now()]);
|
||||
return (await this.prospects(email)).find(x => x.id === r.insertId) || null;
|
||||
},
|
||||
async removeProspect(email, id) { const r = await db.q('DELETE FROM prospects WHERE id=? AND owner_email=?', [Number(id), email]); return r.affectedRows > 0; },
|
||||
async addView(token, angle, ts, ref) { await db.q('INSERT INTO join_views (token,angle,ts,ref) VALUES (?,?,?,?)', [token, angle, ts, ref || null]); },
|
||||
async addClick(campaignId, src, ts) { await db.q('INSERT INTO click_sources (campaign_id,src,ts) VALUES (?,?,?)', [Number(campaignId), src, ts]); },
|
||||
async clicksFor(ids) { if (!ids.length) return []; const rows = await db.q('SELECT campaign_id, src, ts FROM click_sources WHERE campaign_id IN (' + ids.map(() => '?').join(',') + ')', ids); return rows.map(r => ({ campaignId: r.campaign_id, src: r.src, ts: Number(r.ts) })); },
|
||||
async views(tokens, since) {
|
||||
if (!tokens.length) return [];
|
||||
const rows = await db.q('SELECT token, angle, ts, ref FROM join_views WHERE ts>=? AND token IN (' + tokens.map(() => '?').join(',') + ')', [since, ...tokens]);
|
||||
return rows.map(r => ({ token: r.token, angle: r.angle, ts: Number(r.ts), ref: r.ref || '' }));
|
||||
},
|
||||
async viewsSince(since) {
|
||||
const rows = await db.q('SELECT token, angle, ts, ref FROM join_views WHERE ts>=?', [since]);
|
||||
return rows.map(r => ({ token: r.token, angle: r.angle, ts: Number(r.ts), ref: r.ref || '' }));
|
||||
}
|
||||
};
|
||||
const impl = () => db.enabled() ? D : J;
|
||||
|
||||
// ---- prospects (a member's own "people I've talked to" list) ----
|
||||
const STATUSES = ['new', 'contacted', 'interested', 'joined', 'bought', 'not now'];
|
||||
function cleanProspect(b) {
|
||||
const s = v => String(v || '').trim().slice(0, 120);
|
||||
const status = STATUSES.includes(String(b.status || '').toLowerCase()) ? String(b.status).toLowerCase() : 'new';
|
||||
const nextTs = b.nextTs ? Number(b.nextTs) : (b.next ? Date.parse(b.next) : null);
|
||||
return { id: b.id ? Number(b.id) : 0, name: s(b.name), contact: s(b.contact), status, note: String(b.note || '').trim().slice(0, 400), nextTs: nextTs && !isNaN(nextTs) ? nextTs : null };
|
||||
}
|
||||
async function prospects(email) { return impl().prospects(String(email).toLowerCase()); }
|
||||
async function saveProspect(email, body) {
|
||||
const p = cleanProspect(body || {});
|
||||
if (!p.name) return { error: 'Give the prospect a name.' };
|
||||
if (!p.id) { const n = (await prospects(email)).length; if (n >= 500) return { error: 'That is a lot of prospects. Archive some first.' }; }
|
||||
const row = await impl().saveProspect(String(email).toLowerCase(), p);
|
||||
return row ? { ok: true, prospect: row } : { error: 'No such prospect.' };
|
||||
}
|
||||
async function removeProspect(email, id) { return (await impl().removeProspect(String(email).toLowerCase(), id)) ? { ok: true } : { error: 'No such prospect.' }; }
|
||||
|
||||
// ---- link stats: views per angle (join page loads), joins and buyers per angle ----
|
||||
// referring domain from a Referer header: our own pages count as 'direct', empty is 'direct'
|
||||
function refHost(referer) {
|
||||
try { const h = new URL(String(referer || '')).hostname.replace(/^www\./, '').toLowerCase(); return (!h || /linkspin\.com$/.test(h)) ? 'direct' : h.slice(0, 80); } catch (e) { return 'direct'; }
|
||||
}
|
||||
async function recordView(token, angle, referer) { try { await impl().addView(String(token || '').toLowerCase().slice(0, 40), String(angle || '').toLowerCase().slice(0, 20), Date.now(), refHost(referer)); } catch (e) {} }
|
||||
// where an ad click happened: our own surface (by page path) or an outside host
|
||||
function clickSource(referer) {
|
||||
try {
|
||||
const u = new URL(String(referer || '')); const h = u.hostname.replace(/^www\./, '').toLowerCase();
|
||||
if (!/linkspin\.com$/.test(h)) return h.slice(0, 80) || 'unknown';
|
||||
const p = u.pathname;
|
||||
if (p.startsWith('/view')) return 'ad viewer'; if (p.startsWith('/my')) return 'member area'; if (p.startsWith('/ledger')) return 'live ledger';
|
||||
if (p.startsWith('/wall/')) return 'member walls'; if (p.startsWith('/shorts')) return 'shorts'; if (p.startsWith('/plays')) return 'plays page'; if (p === '/' || p === '') return 'home page';
|
||||
return 'site';
|
||||
} catch (e) { return 'unknown'; }
|
||||
}
|
||||
async function recordClick(campaignId, referer) { try { await impl().addClick(Number(campaignId), clickSource(referer), Date.now()); } catch (e) {} }
|
||||
async function clickSources(campaignIds) {
|
||||
const rows = await impl().clicksFor(campaignIds.map(Number));
|
||||
const out = {};
|
||||
for (const r of rows) { out[r.campaignId] = out[r.campaignId] || {}; out[r.campaignId][r.src] = (out[r.campaignId][r.src] || 0) + 1; }
|
||||
return out;
|
||||
}
|
||||
async function linkStats(email) {
|
||||
const a = await accounts.byEmail(email);
|
||||
if (!a) return { angles: [] };
|
||||
const tokens = [a.code, a.username, a.memberId ? String(a.memberId) : null].filter(Boolean).map(t => String(t).toLowerCase());
|
||||
const now = Date.now();
|
||||
const views = await impl().views(tokens, 0);
|
||||
const joined = await accounts.listByReferrer(tokens);
|
||||
const ANG = ['', 'instant', 'adspend', 'free', 'ledger', 'two'];
|
||||
const rows = {};
|
||||
for (const k of ANG) rows[k] = { angle: k || 'plain', views30: 0, views: 0, joins: 0, buyers: 0 };
|
||||
const src = {};
|
||||
const srcRow = k => (src[k] = src[k] || { source: k, views30: 0, views: 0, joins: 0, buyers: 0 });
|
||||
for (const v of views) { const r = rows[v.angle] || rows['']; r.views += 1; if (now - v.ts < 30 * DAY) r.views30 += 1; const s = srcRow(v.ref || 'direct'); s.views += 1; if (now - v.ts < 30 * DAY) s.views30 += 1; }
|
||||
for (const j of joined.slice(0, 300)) {
|
||||
const r = rows[String(j.joinedVia || '').toLowerCase()] || rows[''];
|
||||
r.joins += 1;
|
||||
const s = srcRow(j.joinedRef || 'direct'); s.joins += 1;
|
||||
if (j.memberId) { const mm = await member(j.memberId); if (mm && mm.countedAsBuyer) { r.buyers += 1; s.buyers += 1; } }
|
||||
}
|
||||
const sources = Object.values(src).sort((a, b) => (b.views + b.joins * 5) - (a.views + a.joins * 5));
|
||||
return { angles: Object.values(rows), sources, totalViews: views.length, totalJoins: joined.length };
|
||||
}
|
||||
|
||||
// ---- automatic nudges to stalled members + weekly digest to sponsors ----
|
||||
let ticking = false;
|
||||
const NUDGE_GAP = 5 * DAY;
|
||||
async function nudgeTick() {
|
||||
if (ticking || !mailer || !mailer.hasKey()) return { nudges: 0, digests: 0 };
|
||||
ticking = true;
|
||||
let nudges = 0, digests = 0;
|
||||
try {
|
||||
const now = Date.now();
|
||||
const all = await accounts.listAll(5000);
|
||||
for (const acct of all) {
|
||||
if (!acct.email || !acct.created || now - acct.created < STALL_DAYS * DAY) continue;
|
||||
const c = await describe(acct, now);
|
||||
if (!c.stalled) continue;
|
||||
const R = RUNGS[c.rung]; if (!R.mail) continue;
|
||||
const last = await impl().lastNudge(acct.email);
|
||||
if (last && (last.rung === c.rung || now - last.ts < NUDGE_GAP)) continue; // one email per rung, never more than one every 5 days
|
||||
const spon = await accounts.sponsorOf(acct.email);
|
||||
const who = spon ? (spon.username ? '@' + spon.username : 'your sponsor') : null;
|
||||
const body = R.mail[1] + '\n\nOpen your dashboard: https://linkspin-test.saasy.top/my' + (who ? '\n\nStuck? Message ' + who + ' from My line, that is what they are there for.' : '') + '\n\nLinkSpin';
|
||||
try { await mailer.send(acct.email, R.mail[0], body); await impl().setNudge(acct.email, c.rung, now); nudges += 1; }
|
||||
catch (e) { console.error('nudge', acct.email, e.message); }
|
||||
if (nudges >= 40) break; // spread the load across ticks
|
||||
}
|
||||
// weekly member email (Marty, 2026-09-14, modelled on the mailer.gold weekly): credits sitting unspent,
|
||||
// the streak, the contest standings with the member's own rank, the tank, and the line for sponsors
|
||||
let lbWeek = null; try { lbWeek = X.lb ? await X.lb().view('week') : null; } catch (e) {}
|
||||
let tankN = 0; try { tankN = X.tank ? (await X.tank.waiting()).length : 0; } catch (e) {}
|
||||
for (const acct of all) {
|
||||
if (!acct.email) continue;
|
||||
const lastD = await impl().lastDigest(acct.email);
|
||||
if (now - lastD < 7 * DAY) continue;
|
||||
if (now - (acct.lastSeen || acct.created || 0) > 45 * DAY) { await impl().setDigest(acct.email, now); continue; } // gone quiet for six weeks: leave them to the nudges
|
||||
const view = await coachView(acct.email);
|
||||
// audience: sponsors with directs (as before) unless the admin switched the weekly on for every member
|
||||
const everyone = X.siteConfig && String(X.siteConfig().memberWeeklyEmail || '0') === '1';
|
||||
if (!everyone && !view.directs.length) { await impl().setDigest(acct.email, now); continue; }
|
||||
const name = acct.username ? '@' + acct.username : 'there';
|
||||
const parts = ['Hi ' + name + ', your week on LinkSpin:'];
|
||||
// credits
|
||||
try {
|
||||
const ids = [acct.memberId, ...(await accounts.positions(acct.email)).map(p => p.memberId)].filter(Boolean);
|
||||
const bal = await X.ads.balances(ids, acct.email);
|
||||
if (bal.available > 0) parts.push('- You have ' + bal.available.toLocaleString() + ' ad credits sitting unspent. That is ' + bal.available.toLocaleString() + ' cents of delivery doing nothing. Campaigns > New campaign, point it at your invite link: https://linkspin-test.saasy.top/my#campaigns');
|
||||
else if (bal.inCampaigns > 0) parts.push('- All ' + bal.inCampaigns.toLocaleString() + ' of your credits are working in live campaigns. Good.');
|
||||
} catch (e) {}
|
||||
// streak
|
||||
try {
|
||||
const st = await X.ads.viewStatus(acct.email);
|
||||
if (st.streakDay > 1) parts.push('- Your claim streak is on day ' + st.streakDay + '. Today\'s claim pays ' + (st.claimCredits || 0) + ' credits; miss a day and it restarts at 5.');
|
||||
else parts.push('- Five ads and a claim a day is the free way in: 5, 7, 10, then 25 credits every seventh day in a row: https://linkspin-test.saasy.top/my#earn');
|
||||
} catch (e) {}
|
||||
// contest
|
||||
if (lbWeek && lbWeek.top) {
|
||||
const me = lbWeek.top.find(r => r.name === '@' + acct.username) || null;
|
||||
let mine = me; if (!mine) { try { mine = (await X.lb().view('week', acct.email)).me; } catch (e) {} }
|
||||
parts.push('- Referral contest this week (' + (lbWeek.prize || 'credits to the top 3') + '): ' + (lbWeek.top.length ? lbWeek.top.slice(0, 3).map(r => r.rank + '. ' + r.name + ' (' + r.sales + ' sold)').join(', ') : 'no sales yet, first sale takes the top spot') + '.'
|
||||
+ (mine ? ' You are #' + mine.rank + ' with ' + mine.sales + ' sold.' : ' You are not on the board yet; one $20 package bought by someone you sponsor puts you there.') + ' https://linkspin-test.saasy.top/leaderboard');
|
||||
}
|
||||
// tank
|
||||
if (tankN > 0 && acct.memberId) parts.push('- ' + tankN + ' member' + (tankN === 1 ? ' is' : 's are') + ' waiting for a sponsor in the holding tank. Adopt one from My line: https://linkspin-test.saasy.top/my#line');
|
||||
// line (sponsors only)
|
||||
if (view.directs.length) {
|
||||
const week = view.directs.filter(d => now - d.joined < 7 * DAY).length;
|
||||
parts.push('- Your line: ' + view.directs.length + ' direct' + (view.directs.length === 1 ? '' : 's') + ', ' + week + ' joined this week, ' + view.stalled + ' gone quiet.');
|
||||
const actions = view.directs.filter(d => d.stalled).slice(0, 3).map(d => ' Message ' + d.name + ': "' + d.say.replace('{{name}}', d.name.replace(/^@/, '')) + '"');
|
||||
if (actions.length) parts.push(actions.join('\n'));
|
||||
}
|
||||
parts.push('\nTwenty minutes, in order: the set, one message to your line, the tank, one conversation outward. https://linkspin-test.saasy.top/blog/the-twenty-minute-day\n\nLinkSpin · https://linkspin-test.saasy.top/my\nNo income is guaranteed. Credits are advertising, not money.');
|
||||
const subject = lbWeek && lbWeek.top && lbWeek.top.length && lbWeek.top[0].name === '@' + acct.username ? 'You are #1 this week on LinkSpin' : 'Your week on LinkSpin: credits, streak, contest';
|
||||
try { await mailer.send(acct.email, subject, parts.join('\n')); digests += 1; } catch (e) { console.error('digest', acct.email, e.message); }
|
||||
await impl().setDigest(acct.email, now);
|
||||
if (digests >= 30) break;
|
||||
}
|
||||
} finally { ticking = false; }
|
||||
return { nudges, digests };
|
||||
}
|
||||
|
||||
let X = {}; // extra refs for the weekly member email (ads, tank, lb getter), 2026-09-14
|
||||
function init(opts) { X = arguments[0] || {};
|
||||
DATA_DIR = opts.dataDir; chain = opts.chain; accounts = opts.accounts; mailer = opts.mailer;
|
||||
J.load();
|
||||
}
|
||||
async function viewsSince(since) { return impl().viewsSince(since); }
|
||||
module.exports = { init, RUNGS, rungFor, describe, coachView, prospects, saveProspect, removeProspect, STATUSES, recordView, linkStats, nudgeTick, recordClick, clickSources, refHost, viewsSince };
|
||||
@@ -0,0 +1,320 @@
|
||||
// MySQL data layer (Marty: real concurrency over flat files).
|
||||
// DATABASE_URL present -> mysql2 pool, schema bootstrap, one-time import of
|
||||
// any existing volume JSON (accounts/sessions/
|
||||
// campaigns) so nothing is lost on the switch.
|
||||
// DATABASE_URL absent -> db.enabled() is false and the modules fall back to
|
||||
// their JSON stores (local dev keeps working).
|
||||
// The chain index stays a file: it is a rebuildable cache of the blockchain.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let pool = null;
|
||||
let DATA_DIR = null;
|
||||
|
||||
function enabled() { return !!process.env.DATABASE_URL; }
|
||||
|
||||
async function init(opts) {
|
||||
DATA_DIR = opts.dataDir;
|
||||
if (!enabled()) return false;
|
||||
const mysql = require('mysql2/promise');
|
||||
pool = mysql.createPool(process.env.DATABASE_URL + '?connectionLimit=10&charset=utf8mb4');
|
||||
await bootstrap();
|
||||
await importJsonOnce();
|
||||
console.log('db: MySQL connected, schema ready');
|
||||
return true;
|
||||
}
|
||||
const q = async (sql, params) => (await pool.query(sql, params))[0];
|
||||
|
||||
async function bootstrap() {
|
||||
await q(`CREATE TABLE IF NOT EXISTS accounts (
|
||||
email VARCHAR(190) PRIMARY KEY,
|
||||
pass VARCHAR(200) NULL,
|
||||
sponsor_ref VARCHAR(32) NOT NULL DEFAULT '',
|
||||
code VARCHAR(16) NOT NULL UNIQUE,
|
||||
address VARCHAR(64) NULL UNIQUE,
|
||||
created BIGINT NOT NULL,
|
||||
last_seen BIGINT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await q(`CREATE TABLE IF NOT EXISTS sessions (
|
||||
token VARCHAR(80) PRIMARY KEY,
|
||||
email VARCHAR(190) NULL,
|
||||
address VARCHAR(64) NULL,
|
||||
member_id INT NOT NULL DEFAULT 0,
|
||||
expires BIGINT NOT NULL,
|
||||
INDEX (expires)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await q(`CREATE TABLE IF NOT EXISTS campaigns (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
owner_email VARCHAR(190) NOT NULL,
|
||||
member_id INT NOT NULL,
|
||||
type VARCHAR(12) NOT NULL,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
target_url VARCHAR(500) NOT NULL,
|
||||
image_url VARCHAR(500) NULL,
|
||||
title VARCHAR(80) NULL,
|
||||
body VARCHAR(200) NULL,
|
||||
budget INT NOT NULL,
|
||||
spent INT NOT NULL DEFAULT 0,
|
||||
accrued INT NOT NULL DEFAULT 0,
|
||||
imps INT NOT NULL DEFAULT 0,
|
||||
clicks INT NOT NULL DEFAULT 0,
|
||||
batch_imps INT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(12) NOT NULL DEFAULT 'active',
|
||||
last_day_charged VARCHAR(10) NULL,
|
||||
created BIGINT NOT NULL,
|
||||
INDEX (owner_email), INDEX (type, status), INDEX (member_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await q(`CREATE TABLE IF NOT EXISTS earned_credits (
|
||||
email VARCHAR(190) PRIMARY KEY,
|
||||
balance INT NOT NULL DEFAULT 0,
|
||||
granted_welcome TINYINT NOT NULL DEFAULT 0,
|
||||
updated BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
const alterSafe0 = async sql => { try { await q(sql); } catch (e) { if (!['ER_DUP_FIELDNAME', 'ER_DUP_KEYNAME'].includes(e.code)) throw e; } };
|
||||
await alterSafe0('ALTER TABLE earned_credits ADD COLUMN milestones VARCHAR(255) NULL'); // comma-joined granted milestone keys
|
||||
await alterSafe0('ALTER TABLE earned_credits ADD COLUMN purchase_grade INT NOT NULL DEFAULT 0'); // part of the pool that is refunded/credited PURCHASED money: shows as purchased, funds login ads
|
||||
await alterSafe0('ALTER TABLE earned_credits ADD COLUMN login_day CHAR(10) NULL'); // last daily-login-bonus day
|
||||
await alterSafe0('ALTER TABLE earned_credits ADD COLUMN login_streak INT NOT NULL DEFAULT 0'); // consecutive-day streak
|
||||
// additive columns (MySQL 8 has no IF NOT EXISTS for columns)
|
||||
const alterSafe = async sql => {
|
||||
try { await q(sql); }
|
||||
catch (e) { if (!['ER_DUP_FIELDNAME', 'ER_DUP_KEYNAME'].includes(e.code)) throw e; }
|
||||
};
|
||||
await alterSafe('ALTER TABLE promo_codes ADD COLUMN funder VARCHAR(190) NULL'); // member-funded partner codes (Nexus), 2026-09-14
|
||||
await alterSafe('ALTER TABLE accounts ADD COLUMN username VARCHAR(30) NULL');
|
||||
await alterSafe('ALTER TABLE accounts ADD UNIQUE KEY uq_username (username)');
|
||||
await alterSafe('ALTER TABLE accounts ADD COLUMN member_id INT NULL');
|
||||
await alterSafe('ALTER TABLE accounts ADD KEY idx_member (member_id)');
|
||||
await alterSafe('ALTER TABLE accounts ADD COLUMN avatar_url VARCHAR(500) NULL');
|
||||
await alterSafe('ALTER TABLE accounts ADD COLUMN bio VARCHAR(600) NULL');
|
||||
await alterSafe('ALTER TABLE accounts ADD COLUMN socials VARCHAR(1200) NULL'); // JSON {platform:url}
|
||||
await alterSafe('ALTER TABLE accounts ADD COLUMN line_banner_url VARCHAR(500) NULL');
|
||||
await alterSafe('ALTER TABLE accounts ADD COLUMN line_target_url VARCHAR(500) NULL');
|
||||
await q(`CREATE TABLE IF NOT EXISTS daily_views (
|
||||
email VARCHAR(190) NOT NULL,
|
||||
day CHAR(10) NOT NULL,
|
||||
views INT NOT NULL DEFAULT 0,
|
||||
claimed TINYINT NOT NULL DEFAULT 0,
|
||||
last_ts BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (email, day)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await alterSafe('ALTER TABLE daily_views ADD COLUMN video_count INT NOT NULL DEFAULT 0'); // watch-to-earn videos/day
|
||||
await q(`CREATE TABLE IF NOT EXISTS solo_inbox (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
campaign_id INT NOT NULL,
|
||||
email VARCHAR(190) NOT NULL,
|
||||
delivered BIGINT NOT NULL,
|
||||
read_ts BIGINT NULL,
|
||||
visited_ts BIGINT NULL,
|
||||
rewarded TINYINT NOT NULL DEFAULT 0,
|
||||
rewarded_day CHAR(10) NULL,
|
||||
UNIQUE KEY uq_solo (campaign_id, email),
|
||||
INDEX (email), INDEX (email, rewarded, rewarded_day)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await alterSafe('ALTER TABLE solo_inbox ADD COLUMN visited_ts BIGINT NULL');
|
||||
// solo message bodies are sanitized rich text; TEXT gives them room
|
||||
await alterSafe('ALTER TABLE campaigns MODIFY body TEXT NULL');
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN cta_label VARCHAR(40) NULL');
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN last_shown_day VARCHAR(10) NULL'); // login ads: charge only days they were actually shown (2026-09-15)
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN width INT NULL'); // banner size (IAB) → also NAS width
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN height INT NULL'); // banner size (IAB) → also NAS height
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_ad_id INT NULL'); // syndicated NAS sponsorads.ID
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_served INT NOT NULL DEFAULT 0'); // NAS impressions already reconciled into spend
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN expires BIGINT NULL'); // featured rotation end time
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN starts BIGINT NULL'); // featured run start (booked day); any type: scheduled start
|
||||
await q(`CREATE TABLE IF NOT EXISTS camp_hours (
|
||||
campaign_id INT NOT NULL, day CHAR(10) NOT NULL, hour TINYINT NOT NULL, n INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (campaign_id, day, hour)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // on-site views per UTC hour, for the by-hour chart
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN house TINYINT NOT NULL DEFAULT 0'); // admin house ad: free, never charged
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN daily_cap INT NULL'); // optional credits/day pacing (banner + text)
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN geo VARCHAR(16) NULL'); // country tiers the campaign shows to ('1,2'); NULL = everyone
|
||||
await q(`CREATE TABLE IF NOT EXISTS camp_geo (
|
||||
campaign_id INT NOT NULL, cc CHAR(2) NOT NULL, n INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (campaign_id, cc)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // on-site serves per viewer country
|
||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN day_spent INT NOT NULL DEFAULT 0');
|
||||
await alterSafe("ALTER TABLE campaigns ADD COLUMN day_key CHAR(10) NULL");
|
||||
// linked positions: extra wallets owned by one email account (Qualified Start).
|
||||
// Each is its own on-chain member sponsored by the account's main member.
|
||||
await q(`CREATE TABLE IF NOT EXISTS positions (
|
||||
address VARCHAR(64) PRIMARY KEY,
|
||||
email VARCHAR(190) NOT NULL,
|
||||
member_id INT NOT NULL DEFAULT 0,
|
||||
created BIGINT NOT NULL,
|
||||
INDEX (email)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await q(`CREATE TABLE IF NOT EXISTS nudges (email VARCHAR(190) PRIMARY KEY, rung INT NOT NULL, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await q(`CREATE TABLE IF NOT EXISTS digests (email VARCHAR(190) PRIMARY KEY, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await q(`CREATE TABLE IF NOT EXISTS promo_codes (
|
||||
code VARCHAR(24) NOT NULL PRIMARY KEY, credits INT NOT NULL, partner VARCHAR(80) NULL, note VARCHAR(200) NULL,
|
||||
max_uses INT NOT NULL DEFAULT 0, expires BIGINT NOT NULL DEFAULT 0, active TINYINT NOT NULL DEFAULT 1, created BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // partner promo codes -> free ad credits
|
||||
await q(`CREATE TABLE IF NOT EXISTS promo_redemptions (
|
||||
code VARCHAR(24) NOT NULL, email VARCHAR(190) NOT NULL, credits INT NOT NULL, via VARCHAR(12) NOT NULL, ts BIGINT NOT NULL,
|
||||
PRIMARY KEY (code, email)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await q(`CREATE TABLE IF NOT EXISTS page_hits (
|
||||
day CHAR(10) NOT NULL, host VARCHAR(80) NOT NULL, path VARCHAR(40) NOT NULL, n INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (day, host, path)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // admin Traffic tab: public page views by referring domain
|
||||
await q(`CREATE TABLE IF NOT EXISTS blog_posts (
|
||||
slug VARCHAR(80) NOT NULL PRIMARY KEY, title VARCHAR(140) NOT NULL, excerpt VARCHAR(300) NULL, body MEDIUMTEXT NULL, cover VARCHAR(300) NULL,
|
||||
tags VARCHAR(200) NULL, status VARCHAR(12) NOT NULL DEFAULT 'draft', author VARCHAR(80) NULL, created BIGINT NOT NULL, updated BIGINT NOT NULL,
|
||||
published_at BIGINT NULL, views INT NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // public blog articles written in Admin > Blog
|
||||
await q(`CREATE TABLE IF NOT EXISTS adoptions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
adoptee VARCHAR(190) NOT NULL, adopter VARCHAR(190) NOT NULL,
|
||||
ts BIGINT NOT NULL, expires BIGINT NOT NULL, status VARCHAR(12) NOT NULL DEFAULT 'open',
|
||||
note VARCHAR(600) NULL, closed BIGINT NULL,
|
||||
INDEX (adoptee), INDEX (adopter), INDEX (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // holding-tank adoptions
|
||||
await q(`CREATE TABLE IF NOT EXISTS lead_marks (
|
||||
\`lead\` VARCHAR(190) PRIMARY KEY, sponsor VARCHAR(190) NULL, contacted_ts BIGINT NULL, warned_ts BIGINT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // dormant-lead rescue: manual "contacted" marks + warning sent
|
||||
await q(`CREATE TABLE IF NOT EXISTS prospects (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
owner_email VARCHAR(190) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
contact VARCHAR(120) NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'new',
|
||||
note VARCHAR(400) NULL,
|
||||
next_ts BIGINT NULL,
|
||||
created BIGINT NOT NULL,
|
||||
updated BIGINT NOT NULL,
|
||||
INDEX (owner_email)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await q(`CREATE TABLE IF NOT EXISTS pipeline_notes (
|
||||
owner_email VARCHAR(190) NOT NULL,
|
||||
person VARCHAR(190) NOT NULL,
|
||||
note VARCHAR(1000) NULL,
|
||||
follow_up BIGINT NULL,
|
||||
tag VARCHAR(20) NULL,
|
||||
updated BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_email, person)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await q(`CREATE TABLE IF NOT EXISTS join_views (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
token VARCHAR(40) NOT NULL,
|
||||
angle VARCHAR(20) NOT NULL DEFAULT '',
|
||||
ts BIGINT NOT NULL,
|
||||
INDEX (token, ts)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await alterSafe('ALTER TABLE join_views ADD COLUMN ref VARCHAR(80) NULL'); // referring domain
|
||||
await q(`CREATE TABLE IF NOT EXISTS click_sources (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
campaign_id INT NOT NULL,
|
||||
src VARCHAR(80) NOT NULL,
|
||||
ts BIGINT NOT NULL,
|
||||
INDEX (campaign_id, ts)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await q(`CREATE TABLE IF NOT EXISTS visit_seen (
|
||||
campaign_id INT NOT NULL,
|
||||
email VARCHAR(190) NOT NULL,
|
||||
day CHAR(10) NOT NULL,
|
||||
ts BIGINT NOT NULL,
|
||||
UNIQUE KEY uq_visit (campaign_id, email),
|
||||
INDEX (email, day)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await q(`CREATE TABLE IF NOT EXISTS sponsor_messages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
from_member INT NOT NULL,
|
||||
from_email VARCHAR(190) NOT NULL,
|
||||
to_email VARCHAR(190) NOT NULL,
|
||||
subject VARCHAR(160) NOT NULL,
|
||||
body TEXT NULL,
|
||||
sent BIGINT NOT NULL,
|
||||
read_ts BIGINT NULL,
|
||||
INDEX (to_email, read_ts), INDEX (from_email, sent)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
// sponsor CHAT (two-way) rides the same table: kind='chat' vs the default 'broadcast'
|
||||
await alterSafe(`ALTER TABLE sponsor_messages ADD COLUMN kind VARCHAR(12) NOT NULL DEFAULT 'broadcast'`);
|
||||
await alterSafe('ALTER TABLE sponsor_messages ADD INDEX idx_pair (to_email, from_email)');
|
||||
// per-account chat settings: availability toggle (default on) + muted-member list (JSON emails)
|
||||
await alterSafe('ALTER TABLE accounts ADD COLUMN chat_available TINYINT NOT NULL DEFAULT 1');
|
||||
await alterSafe('ALTER TABLE accounts ADD COLUMN chat_mutes VARCHAR(4000) NULL');
|
||||
await q(`CREATE TABLE IF NOT EXISTS video_seen (
|
||||
email VARCHAR(190) NOT NULL,
|
||||
campaign_id INT NOT NULL,
|
||||
day CHAR(10) NOT NULL,
|
||||
ts BIGINT NOT NULL,
|
||||
UNIQUE KEY uq_vseen (email, campaign_id, day),
|
||||
INDEX (email, day)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await q(`CREATE TABLE IF NOT EXISTS ad_reports (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
campaign_id INT NOT NULL,
|
||||
reporter VARCHAR(190) NOT NULL DEFAULT '',
|
||||
reason VARCHAR(20) NOT NULL,
|
||||
note VARCHAR(600) NULL,
|
||||
ts BIGINT NOT NULL,
|
||||
resolved TINYINT NOT NULL DEFAULT 0,
|
||||
INDEX (resolved, ts), INDEX (campaign_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await q(`CREATE TABLE IF NOT EXISTS burns (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
member_id INT NOT NULL,
|
||||
amount INT NOT NULL,
|
||||
ref VARCHAR(64) NOT NULL,
|
||||
ts BIGINT NOT NULL,
|
||||
burned_tx VARCHAR(80) NULL,
|
||||
burned_at BIGINT NULL,
|
||||
INDEX (burned_tx)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
// follow-up email sequence queue (one row per free account)
|
||||
await q(`CREATE TABLE IF NOT EXISTS drips (
|
||||
email VARCHAR(190) PRIMARY KEY,
|
||||
step INT NOT NULL DEFAULT 0,
|
||||
next_at BIGINT NOT NULL,
|
||||
started BIGINT NOT NULL,
|
||||
stopped TINYINT NOT NULL DEFAULT 0,
|
||||
ref VARCHAR(64) NULL,
|
||||
angle VARCHAR(20) NULL,
|
||||
INDEX (stopped, next_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await alterSafe('ALTER TABLE accounts ADD COLUMN joined_via VARCHAR(20) NULL'); // ?v= angle the lead came in on
|
||||
await alterSafe('ALTER TABLE accounts ADD COLUMN joined_ref VARCHAR(80) NULL'); // referring domain of the first join-page visit
|
||||
await alterSafe('ALTER TABLE accounts ADD COLUMN wall_offers VARCHAR(2000) NULL'); // JSON [{title,bannerUrl,targetUrl}] for wall positions 2-3 (unlock at 2 / 5 qualifying buyers)
|
||||
}
|
||||
|
||||
// one-time import: only when the tables are empty and JSON files exist
|
||||
async function importJsonOnce() {
|
||||
const [{ n }] = await q('SELECT COUNT(*) n FROM accounts');
|
||||
if (n > 0) return;
|
||||
try {
|
||||
const a = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'accounts.json'), 'utf8'));
|
||||
for (const acc of Object.values(a.byEmail || {})) {
|
||||
await q('INSERT IGNORE INTO accounts (email,pass,sponsor_ref,code,address,created) VALUES (?,?,?,?,?,?)',
|
||||
[acc.email, acc.pass || null, String(acc.sponsorRef || acc.sponsorId || ''), acc.code, acc.address || null, acc.created || Date.now()]);
|
||||
}
|
||||
console.log('db: imported', Object.keys(a.byEmail || {}).length, 'account(s) from JSON');
|
||||
} catch (e) {}
|
||||
try {
|
||||
const c = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'campaigns.json'), 'utf8'));
|
||||
for (const cp of c.campaigns || []) {
|
||||
await q(`INSERT IGNORE INTO campaigns (owner_email,member_id,type,name,target_url,image_url,title,body,
|
||||
budget,spent,accrued,imps,clicks,batch_imps,status,last_day_charged,created)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
[cp.owner, cp.memberId, cp.type, cp.name, cp.targetUrl, cp.imageUrl || null, cp.title || null, cp.body || null,
|
||||
cp.budget, cp.spent || 0, cp.accrued || 0, cp.imps || 0, cp.clicks || 0, cp.batchImps || 0,
|
||||
cp.status, cp.lastDayCharged || null, cp.created || Date.now()]);
|
||||
}
|
||||
for (const b of c.burnsPending || []) {
|
||||
await q('INSERT IGNORE INTO burns (id,member_id,amount,ref,ts,burned_tx,burned_at) VALUES (?,?,?,?,?,?,?)',
|
||||
[b.id, b.memberId, b.amount, b.ref, b.ts, b.burnedTx || null, b.burnedAt || null]);
|
||||
}
|
||||
console.log('db: imported', (c.campaigns || []).length, 'campaign(s) from JSON');
|
||||
} catch (e) {}
|
||||
try {
|
||||
const s = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'sessions.json'), 'utf8'));
|
||||
for (const [tok, sess] of Object.entries(s)) {
|
||||
if (sess.expires > Date.now()) {
|
||||
await q('INSERT IGNORE INTO sessions (token,email,address,member_id,expires) VALUES (?,?,?,?,?)',
|
||||
[tok, sess.email || null, sess.address || null, sess.memberId || 0, sess.expires]);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
module.exports = { init, enabled, q: (...a) => q(...a) };
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
linkspin:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
DATA_DIR: /app/data
|
||||
volumes:
|
||||
- linkspin-data:/app/data
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
linkspin-data:
|
||||
@@ -0,0 +1,68 @@
|
||||
# LinkSpin Partner Kit (site owners)
|
||||
|
||||
*Plain-text version of the shareable page. Written for site owners who already know Marty and already run a downline builder.*
|
||||
|
||||
Video overview (4 min 46 s): https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/partner-overview.mp4
|
||||
|
||||
## Why I built it
|
||||
|
||||
You know I've been running ad sites for years, the kind your members already know: buy a package, run banners and text ads, click for credits. Two of mine, Faucet Wave and Tier One Ads, ran on a licensed script. The vendor went out of business, their license server went dark, and it crippled licenses that were fully paid. The sites died overnight and nothing I could do would bring them back.
|
||||
|
||||
So I built LinkSpin from scratch as part of the Crypto Team Build Network. No vendor, no license server, and the part that always went wrong on ad sites, the money, is handled by a verified smart contract on Polygon instead of by me. When a package sells, the contract splits the payment and sends it in the same transaction. I never hold member funds, so there is no back office, no payday, and nothing anyone can switch off.
|
||||
|
||||
## What LinkSpin is
|
||||
|
||||
An advertising platform where the ad spend in your line pays you. Members join free with an email address, no password and no wallet on day one. They earn credits by viewing ads and can run their first campaign for zero dollars. When they want more reach they buy an ad package, and every package that sells is split by the contract the moment it sells.
|
||||
|
||||
- Seven ad formats: banners, text ads, login ads, solo ads to member inboxes, video, featured links and verified visits. Views are timed on the server.
|
||||
- Banner and text ads also push out to Network Ad Space, a partner rotation across other member sites.
|
||||
- Daily caps, country tiers, start and end scheduling, and a balance rule that reserves a campaign's budget when it starts.
|
||||
- Full WalletConnect integration: members link MetaMask, Trust, Phantom, SafePal or any WalletConnect wallet with one tap and one free signature, mobile or desktop, and buy or get paid straight from that wallet. No custody on my side.
|
||||
- Every payout is a public transaction on Polygon. Splits and qualification rules are constants in a verified contract the operator cannot change.
|
||||
|
||||
## How the money moves
|
||||
|
||||
Every package splits the same way, in the same transaction: 50% direct sponsor, 20% level 2, 10% level 3, 20% platform. On a $20 package: $10, $4, $2, $4. A level with no qualified member passes its share up to the next qualified person above.
|
||||
|
||||
Qualification is earned, never bought. Every direct buyer pays their sponsor 50% from their first package. Two qualifying buyers ($20 or larger) open level 2, five open level 3.
|
||||
|
||||
| Package | Price | Credits |
|
||||
|---|---|---|
|
||||
| Micro | $5 | 500 |
|
||||
| Activation (the qualifying buy) | $20 | 2,000 |
|
||||
| Builder | $50 | 5,500 |
|
||||
| Growth | $100 | 12,000 |
|
||||
| Leader | $250 | 32,500 |
|
||||
|
||||
## What your members get
|
||||
|
||||
- A free start: join by email, view ads, earn credits, run a real campaign for nothing.
|
||||
- Your promo credits on top, added the moment they join with your code.
|
||||
- Instant, public payouts in POL to their own wallet, checkable on Polygonscan.
|
||||
- Tools: invite message, social posts, email swipes, a banner kit in every size, objection answers, a public profile wall.
|
||||
- Training videos and written team-building plays.
|
||||
- A holding tank so members who arrive without a sponsor get adopted by qualified builders.
|
||||
- Coaching built in: next-move card, nudges, and a live payments topic on Telegram.
|
||||
|
||||
## What you get for listing it in your builder
|
||||
|
||||
- Your spot at the top: you join directly under the company at the top, no sponsor in between, via the company link Marty sends you. Claim it by activating your account with at least the $20 package.
|
||||
- Your own promo code: reusable, adds free ad credits per redemption, optional cap and expiry, one use per account, every redemption logged and visible.
|
||||
- Your own line: your join link in your builder puts every member under you; each buyer pays you 50% of their first package and every one after, and their buyers open your level 2 and 3 shares.
|
||||
- Ready-made creatives: banners in 468x60, 728x90, 300x250, 160x600, 120x600, 1200x630, text ad copy, email swipes, a program description.
|
||||
- A bridge page for your brand on request, in LinkSpin's design, carrying your code.
|
||||
- Attribution you can check: signups and buyers are tagged with their source.
|
||||
|
||||
## Setting it up
|
||||
|
||||
1. Join with the company link Marty sends you (top placement, no sponsor in between) and pick your username. Your link: linkspin-test.saasy.top/join/yourname
|
||||
2. Send Marty your username and the site. He mints your code (for example YOURSITE) with the agreed credits.
|
||||
3. Add LinkSpin to your builder with https://linkspin-test.saasy.top/join/yourname?promo=YOURSITE
|
||||
4. Use the banners and text ads from the kit. Existing members type the code into "Have a promo code?" on their Overview.
|
||||
5. Link a wallet, switch on payouts, and activate with at least the $20 package to claim your spot at the top.
|
||||
|
||||
## The honest part
|
||||
|
||||
LinkSpin sells advertising. Members earn from the ad packages people in their line buy, and nothing else. There is no earn-without-referring option, on purpose. No income is guaranteed, results depend on effort, and cryptocurrency involves risk of loss. The contract, the ledger and every payout are public.
|
||||
|
||||
Contact: Marty Bostick, marty@marketingwithmarty.com, linkspin-test.saasy.top, t.me/cryptoteambuild
|
||||
@@ -0,0 +1,69 @@
|
||||
# LinkSpin angle videos + squeeze pages (plan, 2026-09-09)
|
||||
|
||||
Same machine RM Circle runs: a short hook video per angle, a matched squeeze
|
||||
page at `linkspin-test.saasy.top/join/<you>?v=<angle>`, a card in Promo tools with the
|
||||
matched link, and paste-ready copy. Draft for Marty's review. Nothing here is
|
||||
built yet except what "Already in place" lists.
|
||||
|
||||
## Already in place
|
||||
|
||||
- Join links: `/join/<username | share code | member #>` set the sponsor cookie and 302 to the home page. No `?v=` handling yet.
|
||||
- Promo tools pane: personalized posts, one email swipe, banner wall link, banner images. No videos, no matched links.
|
||||
- Home page has the signature visuals to reuse: the animated ledger mockup (member #7 buys, level 1/2/3 payouts land), the 3-generation isometric viz, the package ladder.
|
||||
- Free distribution for the finished videos: the video ad format on the site itself (house ads, no cost) and NAS syndication for banner/text.
|
||||
- Video pipeline from RM Circle: HyperFrames (Node 22) + ElevenLabs "Marty Normal Voice", build.py timing from VO files, lint, render, loudnorm, compress, hash-named under `public/v/`.
|
||||
|
||||
## Contract facts every script must stay inside
|
||||
|
||||
- Packages: $5 / $20 / $50 / $100 / $250 mint 500 / 2,000 / 5,500 / 12,000 / 32,500 credits. One credit = one cent of ad delivery.
|
||||
- Split on every purchase, in the same Polygon transaction: 50% direct sponsor, 20% level 2, 10% level 3, 20% platform. Paid to wallets, nothing to claim.
|
||||
- Qualification to receive: level 1 pays any activated sponsor; level 2 needs 2 qualifying buyers ($20+); level 3 needs 5.
|
||||
- Seven ad formats: banner, text, login, solo, video, featured link, verified visits. Members earn credits by viewing.
|
||||
- Join is free by email. Wallet comes out at purchase or when switching payouts on.
|
||||
- Dollar package prices are fixed constants and may be spoken. POL amounts and POL/USD are never baked into audio or frames (evergreen rule).
|
||||
- Every video ends on the disclaimer card: "No income guaranteed. Results depend on your effort. Crypto involves risk of loss."
|
||||
|
||||
## The five angles
|
||||
|
||||
| key | working title | hook (first line) | for whom | shape | proof beat |
|
||||
|---|---|---|---|---|---|
|
||||
| `instant` | Paid before the page reloads | "What if your commission landed before the thank-you page finished loading?" | everyone; flagship | PAS, ~60s | ledger rows animate: purchase, three payouts, same block |
|
||||
| `adspend` | You were buying traffic anyway | "Every ad dollar you have ever spent went one direction. Out." | marketers who buy ads | AIDA, ~55s | package ladder + "your buyers' ad spend pays your line" |
|
||||
| `free` | Watch first, spend never | "You can run your first ad campaign here for exactly zero dollars." | tire kickers, earn-side | PAS, ~50s | earn credits by viewing, welcome credits, first campaign |
|
||||
| `ledger` | No back office. No payday. | "Your last affiliate program paid you on the 15th. If it paid you." | skeptics, burned affiliates | PAS, ~55s | Polygonscan / live ledger as the payroll |
|
||||
| `two` | Two buyers open level two | "Two buyers. Then five. That is the whole ladder." | builders | AIDA, ~55s | 3-generation viz lights up 50 / 20 / 10 |
|
||||
|
||||
Recommended first three: `instant`, `adspend`, `free`. `ledger` and `two` follow once the page mechanism is live. Landscape 16:9 for the squeeze page; a 9:16 cut of each for social if Marty wants it.
|
||||
|
||||
## Squeeze page mechanics
|
||||
|
||||
- `/join/<token>?v=<angle>` serves `join.html`: hook headline, video with poster, three proof chips (same transaction, public ledger, join free), "You're joining under @username", inline email join. Plain `/join/<token>` keeps redirecting to the home page.
|
||||
- Sponsor cookie set exactly as today. The `v` also rides into the account (`sponsor_ref` stays as is; add `joined_via`) so we can see which hook converts.
|
||||
- Per-angle og/twitter tags with `og:url` INCLUDING `?v=` (Facebook canonicalizes shares to og:url; RM Circle lost its variants once this way).
|
||||
- Promo tools: one card per video with preview, "Copy matched link", download buttons for both cuts, a post per network, an SMS-length message, and share buttons (Text, WhatsApp, Telegram, Copy).
|
||||
- Each finished video also goes live as a house video ad and its squeeze page as a house login ad.
|
||||
|
||||
## Production pipeline (mirrors RM Circle)
|
||||
|
||||
1. Scripts: written from this brief through the bv-tester1 engine with the contract facts pasted in, then checked line by line against the facts above. No em dashes, no income claims, no POL figures.
|
||||
2. Project per video at `D:\Projects\HighRisk\onchain-ad-membership\video-<angle>` (assets/scenes.json, tts.cjs, build.py, index.html), same as `video-phone`.
|
||||
3. Silent draft first, rendered with the site palette (mint #43e8c3 on #0b1512, Sora display, mono ledger rows). Marty approves visuals and copy.
|
||||
4. Voice: ElevenLabs clone by default, or Marty records to a teleprompter script (6 files, vo1 to vo6). Timing re-derives from the audio either way.
|
||||
5. Render 1080p30, loudnorm to -14 LUFS, compress (crf 26, faststart), hash-name into `public/v/`, poster frame, og image per angle.
|
||||
6. Ship the squeeze page, promo cards, house ads, and the chatbot answer update together.
|
||||
|
||||
## Order of work
|
||||
|
||||
1. Marty picks the first three angles and the voice route.
|
||||
2. Five scripts drafted and verified; Marty approves.
|
||||
3. Squeeze page + og tags + promo cards shipped with poster placeholders, so links work before videos exist.
|
||||
4. Silent drafts, approval, voiced finals, deploy.
|
||||
5. House ads placed, launch posts in Marty's voice on massifly and Telegram.
|
||||
|
||||
## Decisions for Marty
|
||||
|
||||
- Which three angles first? (Recommendation above.)
|
||||
- Voice clone or your own recording?
|
||||
- Squeeze page CTA: inline email join (recommended) or send to the home page?
|
||||
- Do you want 9:16 cuts for socials from day one?
|
||||
- Scripts through the Branded Voice engine, or written directly in your style?
|
||||
@@ -0,0 +1,84 @@
|
||||
# LinkSpin team-building plays
|
||||
|
||||
Working doc, 2026-09-09. Three ways to build a line, written so a member can pick one and run it. Every number below comes from the live contract and rate table (50 / 20 / 10 / 20 split; qualifying buyer = a direct who buys a $20+ package; level 2 opens at 2 qualifying, level 3 at 5; milestone credits 10 / 25 / 50 / 100; featured strip 40 credits a day; one broadcast a day; wall position 2 at 2 qualifying, position 3 at 5).
|
||||
|
||||
## The one rule under all three plays
|
||||
|
||||
Unqualified levels pass up. If someone on your level 2 buys before you have 2 qualifying buyers, that 20% does not wait for you. It goes to the next qualified sponsor above you (or the platform). Same for level 3 and 5. So whatever play you run, the first job is the same: get qualified before your line gets busy. The dashboard "Your next move" card is the ladder; the plays are how you climb it.
|
||||
|
||||
## Play 1: Wide and teach ("the fifty play")
|
||||
|
||||
Who it fits: someone with an audience, a list, a group, or traffic they can point somewhere. Time-rich or reach-rich.
|
||||
|
||||
Why it works: every direct who buys is 50% to you, instantly, forever. Directs are the only thing that qualifies you. And the teaching is what fills levels 2 and 3 without you doing anything extra: your directs' buyers are your 20%, their buyers are your 10%.
|
||||
|
||||
The move:
|
||||
1. One new conversation a day, minimum. Use the Text a friend and Social posts in Promo tools; every piece already carries your link.
|
||||
2. Point paid traffic at an angle lander, not the bare link: `?v=adspend` for advertisers, `?v=free` for freebie seekers, `?v=instant` for the crypto-curious.
|
||||
3. Every new direct gets the same three sentences from you inside 24 hours (sponsor chat or one broadcast): "Pick your username. Link your wallet and switch on payouts. Send your link to one person today." That is the whole teaching. They teach it to their people.
|
||||
4. Run the network's own ads at your link: buy a package, or claim the daily 5 credits, and spend the credits on a Featured link (40 credits a day) or a text ad pointed at your angle lander. Recruits from inside the network already understand the product.
|
||||
|
||||
Scoreboard: Joined your line (should climb daily), Qualifying buyers (2 then 5), then watch level 2 and 3 rows appear in My line.
|
||||
|
||||
Ceiling: none on width. Weakness: shallow lines churn if you skip step 3.
|
||||
|
||||
## Play 2: Two, then down ("the depth play")
|
||||
|
||||
Who it fits: someone with a small circle who would rather coach two people well than pitch twenty.
|
||||
|
||||
Why it works: two qualifying buyers open level 2 (20%), position 2 on your wall, the Circuit badge, and 50 bonus credits. From there, every person your two bring in pays you 20%, and every person those people bring in pays you 10% (once you reach 5). Your effort goes into two relationships instead of a funnel.
|
||||
|
||||
The move:
|
||||
1. Get two directs to a $20+ package. Sit with them on the buy if you have to (Trust Wallet needs a POL cushion; SafePal or MetaMask are smoother).
|
||||
2. Coach them to their two. Sponsor chat daily for the first week. One broadcast a day to your directs with a single ask each time.
|
||||
3. Set your line banner to your team's meeting place (Telegram group, a training page). Every new member three levels down meets it on their welcome tour. That is how your coaching reaches people you never directly recruited.
|
||||
4. Keep adding directs until you have five. This is the catch in the depth play: level 3 only opens on five qualifying directs of your own. Two deep, coached well, gets you a healthy 20% level. It does not get you the 10% level.
|
||||
|
||||
Scoreboard: Qualifying buyers 2, then level 2 count in My line rising, then your directs' own Qualifying buyers (ask them, or read their wall page).
|
||||
|
||||
Ceiling: level 2 income until you personally hit five. Strength: the stickiest lines come from this play.
|
||||
|
||||
## Play 3: Five and wide ("the combination", recommended default)
|
||||
|
||||
Who it fits: anyone willing to do both. This is the play the dashboard ladder is actually built for.
|
||||
|
||||
The move, in order:
|
||||
1. Sprint to five qualifying directs. Nothing else matters until level 3 is open: that is Nexus, wall position 3 (your whole public page runs your own links), 100 bonus credits, and the full 50/20/10.
|
||||
2. Then split the day. Mornings wide: one new conversation, one post, one ad running. Evenings deep: read My line, message the three newest directs, send the broadcast.
|
||||
3. Coach the 2-then-5 rule down the line. Each of your five gets pushed to two (your level 2 fills), then to five (your level 3 fills). Use the achievements Share links; people copy what they see rewarded.
|
||||
4. Book the featured strip for 7 days whenever you have 280 credits spare. Ten slots a day, every member sees it.
|
||||
|
||||
Scoreboard: all four tiles, plus Earning levels on the Overview (buyers referred, level open, how many to next).
|
||||
|
||||
## Opening move: Qualified Start (works with any play)
|
||||
|
||||
Qualification is earned by buyers, never bought, but you can be your own first buyers, openly. Under Buy packages, link a second wallet you own as a position. When it buys a $20 package the contract counts it as a qualifying buyer, half the purchase returns to your main wallet, and its credits pool with yours. Two positions open level 2 the same day (about $20 net for $40 of credits); five open level 3 (about $50 net for $100). Say it plainly: your own money, your own wallets, a faster start, never an income promise.
|
||||
|
||||
## Which play fits you
|
||||
|
||||
| You have | Run |
|
||||
|---|---|
|
||||
| A list, a group, or ad budget | Wide and teach |
|
||||
| A few close people and patience | Two, then down |
|
||||
| An hour a day and a phone | Five and wide |
|
||||
|
||||
## First 30 days (any play)
|
||||
|
||||
- Day 1: username, wallet linked, payouts on, welcome tour done (25 credits). Send the link to one person.
|
||||
- Days 2 to 7: one conversation a day. Claim the daily 5 credits. First buyer (25 bonus credits).
|
||||
- Days 8 to 14: second qualifying buyer. Level 2 open. Set your line banner. First broadcast.
|
||||
- Days 15 to 30: coach the two to their two. Add directs three, four, five. Level 3 open by day 30 is the stretch goal.
|
||||
|
||||
## Messages that fit the plays
|
||||
|
||||
- Wide: "I run ads anyway. This one pays me in the same transaction the buyer's package sells, on a public ledger. Free to join by email: {{link}}"
|
||||
- Depth: "I need two people who will actually do this with me, not twenty who will look at it. You are one of the two I thought of. {{link}}"
|
||||
- Combo, to a new direct: "Three things today: username, wallet on, one person. I will check in tomorrow."
|
||||
|
||||
No income is guaranteed. Results depend on your effort. Crypto involves risk of loss. LinkSpin sells advertising; it is not an investment.
|
||||
|
||||
## Where this should live on the site (proposal, not built)
|
||||
|
||||
- Promo tools: a "Plays" pill with the three plays and the fit table.
|
||||
- Sponsor coaching panel (pending build): the coach sees which play each direct is on, from their buyer count and line shape.
|
||||
- Training: a short video per play once the plays are approved.
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"hours": 24,
|
||||
"subject": "What you already own on LinkSpin",
|
||||
"body": "If you created an LinkSpin account yesterday and then got busy, no worries. It happens.\n\nBut I want to circle back on something you may have missed. Right now, sitting in your free account, you already have three things most people never realize they own.\n\n1. Your username is your invite link.\n\nThat free account came with a link that is already set to go. Pick a username, share the link, and anyone who joins through it is in your line. It is yours from day one.\n\n2. Welcome credits that unlock in minutes.\n\nTake the short welcome tour inside your dashboard and the system adds credits to your account. You can use them {{paid:on top of the ad credits you already bought|to run a small test campaign and see how everything works}}.\n\n3. Promo tools with the posts already written.\n\nYour dashboard has a Promo tools section. Inside it: social posts, text messages, email swipes, even banners, all already carrying your invite link. You do not have to write a word.\n\n{{paid:You already jumped in on a package, which is great. The Promo tools make sharing your link the easy part. Everything is set up and ready to go.|This email is not asking you to buy anything. I just wanted to show you what is already in your account, waiting.}}\n\nOne thing to do today: sign in at {{site}}/my, pick your username if you have not yet, and copy your invite link. Just knowing it is there makes everything else easier.\n\nYour link right now: {{link}}\n\nMore tomorrow.\n\nMarty\n\n{{footer}}"
|
||||
},
|
||||
{
|
||||
"hours": 48,
|
||||
"subject": "You can watch every payment live",
|
||||
"body": "A couple of days ago you joined LinkSpin. Free account. Nothing to lose.\n\nMaybe you have looked around. Maybe you have not.\n\nHere is what matters about this platform. The thing that is different from every other traffic system you have seen.\n\nThe money.\n\nWhen anyone on LinkSpin buys an ad package, a verified smart contract on Polygon splits it in the same transaction. No holding. No waiting. Nobody touches the funds before they move.\n\n50 percent goes to their direct sponsor. 20 percent to level two. 10 percent to level three. 20 percent stays with the platform.\n\nEvery cent lands in real wallets within seconds.\n\n{{paid:You have already bought a package, so you have seen this firsthand.|You have not bought a package yet, and here is the thing. You can verify all of this without spending a dime.}}\n\nOpen {{site}}/ledger. Every transaction is on the public ledger. No wallet needed. No login. Just every ad package, every split, every payment, in the order it happened.\n\nScroll through it. Pick a transaction. Follow it.\n\nThis is proof you can check yourself. Not a screenshot. Not a promise in an email. A live blockchain you can inspect right now.\n\nThe money does not sit in a company wallet waiting to be released. It just moves. Instantly. Permanently. On-chain.\n\nSpend a few minutes on the ledger. Then you will understand why this model works differently.\n\nYour link: {{link}}\n\nMarty\n\n{{footer}}"
|
||||
},
|
||||
{
|
||||
"hours": 96,
|
||||
"subject": "That pending feeling..",
|
||||
"body": "A few days ago you signed up for LinkSpin.\n\nMaybe you have already bought an ad package. Maybe you have not. Either way, I want to talk about something most of us in this business have gotten way too used to.\n\nThe hold.\n\nYou make a sale. You wait. Your account says pending. Or under review. Or funds released on the 15th. You refresh the page. Nothing. You email support. They say 3 to 5 business days. Or 7.\n\nIt is so normal we do not even question it anymore.\n\nA platform holds your money for days or weeks. They say it is for security. Or processing. Or some other reason that sounds fine until you think about it.\n\nWho is holding it? And what are they doing with it while you wait?\n\nLinkSpin works differently. It runs on a public blockchain called Polygon. When someone buys a package, a verified smart contract splits the payment in that same transaction. 50 percent goes to their sponsor. 20 to level two. 10 to level three. 20 to the platform.\n\nIt lands in real wallets in seconds.\n\nNothing is ever held. There is nothing to withdraw. Every payment is public on the live ledger.\n\n{{paid:You have already seen it happen. Your purchase paid your sponsor's line instantly. No pending. No waiting. No support ticket. It just showed up.|You can see this for yourself right now. The live ledger is public at {{site}}/ledger. Every transaction. Every split. Every payout. No login, no under review. Just a contract that does what it says, every time. And the $5 package is the cheapest way to watch it happen with your own purchase.}}\n\nYour dashboard is waiting at {{site}}/my.\n\nMarty\n\n{{footer}}"
|
||||
},
|
||||
{
|
||||
"hours": 144,
|
||||
"subject": "What your free account can actually do..",
|
||||
"body": "So you signed up for LinkSpin about a week ago.\n\nFree account, no password, no fuss. Maybe you checked the dashboard, thought okay, cool, and moved on.\n\nLet me show you what is actually sitting in that account.\n\nLinkSpin is an ad platform on the Polygon blockchain. In plain English: people buy ad credits, people view ads and earn credits, and the whole thing runs on a smart contract that pays out instantly.\n\nNow, the ads themselves.\n\nSeven formats: banners, text ads, full-screen login ads, solo ads delivered into member inboxes, video ads, featured links, and verified visits.\n\nEvery single view is dwell-timed on the server. That means a real person sat there long enough for it to count. Not bots. Not drive-bys.\n\nOne credit equals one cent of ad delivery. Simple math.\n\nAnd here is the part that makes it work: members earn credits by viewing ads. So when you run a campaign, your ad is shown to people who are actively watching. They have a reason to pay attention.\n\n{{paid:You already bought a package. Good. Your credits are sitting in your account. Go to the Campaigns tab and launch your first real campaign today. Pick a format and let it run. Watch what happens.|You have not bought a package yet, and you do not have to start with one. Sign in at {{site}}/my. Take the welcome tour for your welcome credits, earn more by viewing ads, and launch a small test campaign with those. See how it feels. See the numbers.}}\n\nEither way, the best way to understand this thing is to actually use it.\n\nYour link: {{link}}\n\nMarty\n\n{{footer}}"
|
||||
},
|
||||
{
|
||||
"hours": 192,
|
||||
"subject": "The one thing nobody tells you about ad platforms..",
|
||||
"body": "This might be the most important email I send you about LinkSpin.\n\nBecause there is a detail in how this works that most people do not notice until it is too late.\n\nIt is not about the ad credits, the formats, or even the commission structure.\n\nIt is about timing.\n\nHere is how it works. Your invite link is already live. If someone clicks it, signs up, and buys their first ad package, any package from $5 to $250, you receive 50 percent of that payment. The smart contract pays you in the same transaction. No holding tank, no withdrawal minimum, no waiting.\n\nThat is the upside everyone talks about.\n\nHere is what nobody mentions.\n\nThe moment a person buys their first package through any link, they are locked to that sponsor permanently. Every future package they buy pays that sponsor's line.\n\nSo if someone you know (a friend, a subscriber, a group contact) joins LinkSpin through someone else's link and buys their first package there, they are locked to that other person for good.\n\n{{paid:You already bought, so your own placement is settled. Your link is live and paying.|If you have not bought yet, your link is still live. It still pays you 50 percent from anyone who clicks it today and buys.}}\n\nBut it will not capture the people you know unless you send it to them.\n\nOne person this week. Just put {{link}} where they will see it. That is all it takes.\n\n{{sponsor}} got their link from somewhere too. Same deal. Now it is your turn to pass it forward.\n\nMarty\n\n{{footer}}"
|
||||
},
|
||||
{
|
||||
"hours": 264,
|
||||
"subject": "Your link is live (and already loaded)",
|
||||
"body": "That link of yours? It pays the moment someone buys.\n\n{{paid:You already know the feeling. Someone buys, the smart contract runs, and 50 percent lands in your wallet. In seconds.|It works like this: someone clicks your link, buys any package, and 50 percent of it lands in your wallet in the same transaction. In seconds.}}\n\nNobody has to wait. No holding period. No withdraw button, because nothing was ever held.\n\nThe money splits in the same transaction the buyer makes. It lands in real wallets. And every payment is public on the live ledger.\n\nBut here is what most people do not use right away. The Promo tools tab in your dashboard.\n\nOpen it and you will find social posts, text messages, email swipes, and banners. All carrying your link. All ready to copy and paste.\n\nPlus an objection bank with honest answers for when someone asks how it works.\n\nTwo qualifying buyers ($20 or more) open level two for you: 20 percent of what their people buy. Five open level three.\n\n{{paid:You are already in motion. Now use the tools to keep going.|You have not bought a package yet. That is fine. The tools work either way. Your link is live whether you buy or not.}}\n\nOne text from Promo tools today. That is all it takes to get your first buyer moving.\n\n{{site}}/my\n\nMarty\n\n{{footer}}"
|
||||
},
|
||||
{
|
||||
"hours": 336,
|
||||
"subject": "Two weeks in. Here is where you are at.",
|
||||
"body": "Two weeks since you joined LinkSpin. Thought it was time for a quick recap of where things stand.\n\nYou have a free account that is already working for you. Your invite link is live. Anyone who joins through it is in your line, and locks to you as their sponsor at their first purchase.\n\n{{paid:You grabbed a package and have credits to advertise with. Every ad you run reaches real people with dwell-timed views across banners, text ads, solo inbox ads, video ads and more. Your budget delivers exactly what you paid for.|You have not picked up an ad package yet, and that is fine. Your free account is ready when you are. Packages start at $5.}}\n\nHere is the referral side, exactly as the contract has it. When someone in your line buys a package, a smart contract splits their payment instantly, and 50 percent goes to you as their direct sponsor. No waiting, no withdrawal step.\n\nTwo qualifying buyers (people you referred who bought $20 or more) open level two: 20 percent of what the people they refer buy. Five qualifying buyers open level three: 10 percent from one level deeper.\n\nEvery payment lands in real wallets in seconds and is public on the ledger. Nothing held, nothing to withdraw.\n\nOne more thing those buyers unlock: your public wall. Two qualifying buyers make the second ad position on your wall yours, five make the third. At that point the whole page runs your links and nobody else's.\n\nYour sponsor {{sponsor}} is right there in your dashboard. Open Messages any time.\n\nAnd the Promo tools are loaded: social posts, text messages, email swipes, banners, an objection bank, all with your invite link baked in.\n\nFrom here, the LinkSpin newsletter takes over with updates and new features.\n\nYou know where your dashboard is: {{site}}/my\nYour link: {{link}}\n\nMarty\n\n{{footer}}"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,182 @@
|
||||
// Follow-up email sequence for new free members ("the lead came in the door
|
||||
// with their email"). A row is queued when an account is created; a ticker
|
||||
// sends each step when it comes due; a signed unsubscribe link stops it.
|
||||
// Sequence copy lives in DATA_DIR/drip.json (admin-editable) with the
|
||||
// defaults below. Dual-mode storage like the rest of the site (MySQL / JSON).
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const db = require('./db');
|
||||
|
||||
let DATA_DIR = null, mailer = null, accounts = null, chain = null, SITE = 'https://linkspin-test.saasy.top';
|
||||
|
||||
// hours = time after the account was created. Copy drafted through Marty's
|
||||
// Branded Voice engine (one framework per email: gain, logic, PAS, logic,
|
||||
// honest fear of loss, AIDA, gain + hand-off to the newsletter), then checked
|
||||
// line by line against the contract facts. Admin overrides live in drip.json.
|
||||
const DEFAULT_SEQUENCE = require('./drip-defaults.json');
|
||||
|
||||
// ---- storage ----
|
||||
const J = {
|
||||
db: null,
|
||||
FILE: () => path.join(DATA_DIR, 'drips.json'),
|
||||
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = {}; } },
|
||||
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} },
|
||||
async enqueue(rec) { if (!this.db) this.load(); if (this.db[rec.email]) return false; this.db[rec.email] = rec; this.save(); return true; },
|
||||
async due(now, limit) { if (!this.db) this.load(); return Object.values(this.db).filter(r => !r.stopped && r.nextAt <= now).sort((a, b) => a.nextAt - b.nextAt).slice(0, limit); },
|
||||
async update(email, fields) { if (!this.db) this.load(); if (this.db[email]) { Object.assign(this.db[email], fields); this.save(); } },
|
||||
async stats() { if (!this.db) this.load(); const v = Object.values(this.db); return { active: v.filter(r => !r.stopped).length, unsubscribed: v.filter(r => r.stopped === 2).length, done: v.filter(r => r.stopped === 1).length, total: v.length }; },
|
||||
async get(email) { if (!this.db) this.load(); return this.db[email] || null; }
|
||||
};
|
||||
const rowR = r => ({ email: r.email, step: r.step, nextAt: Number(r.next_at), started: Number(r.started), stopped: r.stopped, ref: r.ref, angle: r.angle });
|
||||
const D = {
|
||||
async enqueue(rec) {
|
||||
try {
|
||||
await db.q('INSERT INTO drips (email,step,next_at,started,stopped,ref,angle) VALUES (?,?,?,?,0,?,?)',
|
||||
[rec.email, rec.step, rec.nextAt, rec.started, rec.ref || null, rec.angle || null]);
|
||||
return true;
|
||||
} catch (e) { if (e.code === 'ER_DUP_ENTRY') return false; throw e; }
|
||||
},
|
||||
async due(now, limit) { return (await db.q('SELECT * FROM drips WHERE stopped=0 AND next_at<=? ORDER BY next_at LIMIT ?', [now, limit])).map(rowR); },
|
||||
async update(email, fields) {
|
||||
const sets = [], vals = [];
|
||||
if ('step' in fields) { sets.push('step=?'); vals.push(fields.step); }
|
||||
if ('nextAt' in fields) { sets.push('next_at=?'); vals.push(fields.nextAt); }
|
||||
if ('stopped' in fields) { sets.push('stopped=?'); vals.push(fields.stopped); }
|
||||
if (!sets.length) return;
|
||||
vals.push(email);
|
||||
await db.q('UPDATE drips SET ' + sets.join(',') + ' WHERE email=?', vals);
|
||||
},
|
||||
async stats() {
|
||||
const r = await db.q('SELECT SUM(stopped=0) active, SUM(stopped=2) unsubscribed, SUM(stopped=1) done, COUNT(*) total FROM drips');
|
||||
return { active: Number(r[0].active || 0), unsubscribed: Number(r[0].unsubscribed || 0), done: Number(r[0].done || 0), total: Number(r[0].total || 0) };
|
||||
},
|
||||
async get(email) { const r = await db.q('SELECT * FROM drips WHERE email=?', [email]); return r.length ? rowR(r[0]) : null; }
|
||||
};
|
||||
const impl = () => db.enabled() ? D : J;
|
||||
|
||||
// ---- sequence config ----
|
||||
function seqFile() { return path.join(DATA_DIR, 'drip.json'); }
|
||||
function sequence() {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(seqFile(), 'utf8'));
|
||||
if (Array.isArray(saved) && saved.length) return saved.map(normStep).filter(Boolean);
|
||||
} catch (e) {}
|
||||
return DEFAULT_SEQUENCE;
|
||||
}
|
||||
function normStep(s) {
|
||||
if (!s || typeof s !== 'object') return null;
|
||||
const hours = Number(s.hours);
|
||||
const subject = String(s.subject || '').trim().slice(0, 150);
|
||||
const body = String(s.body || '').trim().slice(0, 8000);
|
||||
if (!(hours >= 1) || !subject || !body) return null;
|
||||
return { hours, subject, body };
|
||||
}
|
||||
function setSequence(arr) {
|
||||
if (!Array.isArray(arr)) return { error: 'Send a list of steps.' };
|
||||
const steps = arr.map(normStep);
|
||||
if (steps.some(s => !s)) return { error: 'Every step needs hours (>= 1), a subject and a body.' };
|
||||
if (steps.length > 20) return { error: 'Keep it to 20 steps or fewer.' };
|
||||
for (let i = 1; i < steps.length; i++) if (steps[i].hours <= steps[i - 1].hours) return { error: 'Steps must be in increasing hours.' };
|
||||
fs.writeFileSync(seqFile(), JSON.stringify(steps, null, 2));
|
||||
return { ok: true, sequence: steps };
|
||||
}
|
||||
function resetSequence() { try { fs.unlinkSync(seqFile()); } catch (e) {} return { ok: true, sequence: DEFAULT_SEQUENCE }; }
|
||||
|
||||
// ---- unsubscribe signing ----
|
||||
function secret() {
|
||||
const f = path.join(DATA_DIR, 'drip.secret');
|
||||
try { return fs.readFileSync(f, 'utf8').trim(); } catch (e) {}
|
||||
const s = crypto.randomBytes(24).toString('hex');
|
||||
try { fs.writeFileSync(f, s, { mode: 0o600 }); } catch (e) {}
|
||||
return s;
|
||||
}
|
||||
function token(email) { return crypto.createHmac('sha256', secret()).update(String(email).toLowerCase()).digest('hex').slice(0, 32); }
|
||||
function unsubUrl(email) { return SITE + '/unsubscribe?e=' + encodeURIComponent(email) + '&t=' + token(email); }
|
||||
async function unsubscribe(email, t) {
|
||||
const e = String(email || '').trim().toLowerCase();
|
||||
if (!e || !t || t !== token(e)) return { error: 'That link is not valid.' };
|
||||
const cur = await impl().get(e);
|
||||
if (!cur) { try { await enqueue(e, '', ''); await impl().update(e, { stopped: 2 }); } catch (err) {} return { ok: true }; } // member without a drip row: record the opt-out anyway (update emails honour it)
|
||||
await impl().update(e, { stopped: 2 });
|
||||
return { ok: true };
|
||||
}
|
||||
async function isUnsubscribed(email) { const cur = await impl().get(String(email || '').toLowerCase()); return !!(cur && cur.stopped === 2); }
|
||||
|
||||
// ---- rendering ----
|
||||
async function vars(email) {
|
||||
const a = accounts ? await accounts.byEmail(email) : null;
|
||||
const tok = a ? (a.username || a.code || (a.memberId ? String(a.memberId) : '')) : '';
|
||||
let sponsor = 'your sponsor';
|
||||
try {
|
||||
const sp = accounts && await accounts.sponsorOf(email);
|
||||
if (sp) sponsor = sp.username ? '@' + sp.username : (sp.memberId ? 'member #' + sp.memberId : 'your sponsor');
|
||||
} catch (e) {}
|
||||
// paid = has bought at least one package (on-chain credits minted); used by {{paid:a|b}}
|
||||
let paid = false;
|
||||
try { if (a && a.memberId && chain) paid = (await chain.creditBalance(a.memberId, 0)) > 0; } catch (e) {}
|
||||
return {
|
||||
link: tok ? SITE + '/join/' + tok : SITE + '/my',
|
||||
sponsor, site: SITE, email, paid,
|
||||
footer: 'You are getting these follow-ups because you created a free LinkSpin account. Stop them here: ' + unsubUrl(email)
|
||||
+ '\n\nLinkSpin · Advertising, not investing. No income is guaranteed; results depend on your effort. Crypto carries risk of loss.'
|
||||
};
|
||||
}
|
||||
// simple {{key}} placeholders resolve first (they may sit inside a branch),
|
||||
// then {{paid:words if they bought|words if not}} picks the branch
|
||||
function render(text, v) {
|
||||
return String(text)
|
||||
.replace(/\{\{(\w+)\}\}/g, (m, k) => (k in v && typeof v[k] !== 'boolean' ? v[k] : m))
|
||||
.replace(/\{\{paid:([\s\S]*?)\|([\s\S]*?)\}\}/g, (m, yes, no) => (v.paid ? yes : no));
|
||||
}
|
||||
|
||||
// ---- lifecycle ----
|
||||
function init(opts) {
|
||||
DATA_DIR = opts.dataDir; mailer = opts.mailer; accounts = opts.accounts; chain = opts.chain || null;
|
||||
if (opts.site) SITE = opts.site;
|
||||
}
|
||||
// queue a fresh account; step 0 of the sequence is due `hours` after creation
|
||||
async function enqueue(email, ref, angle) {
|
||||
const e = String(email || '').trim().toLowerCase();
|
||||
if (!e) return false;
|
||||
const seq = sequence();
|
||||
const now = Date.now();
|
||||
return impl().enqueue({ email: e, step: 0, nextAt: now + seq[0].hours * 3600000, started: now, stopped: 0,
|
||||
ref: String(ref || '').slice(0, 40) || null, angle: String(angle || '').slice(0, 20) || null });
|
||||
}
|
||||
async function sendStep(email, stepIdx, to) {
|
||||
const seq = sequence();
|
||||
const s = seq[stepIdx];
|
||||
if (!s) return { error: 'No such step.' };
|
||||
const v = await vars(email);
|
||||
await mailer.send(to || email, render(s.subject, v), render(s.body, v));
|
||||
return { ok: true };
|
||||
}
|
||||
let ticking = false;
|
||||
async function tick() {
|
||||
if (ticking || !mailer || !mailer.hasKey()) return 0;
|
||||
ticking = true;
|
||||
let sent = 0;
|
||||
try {
|
||||
const seq = sequence();
|
||||
const now = Date.now();
|
||||
const rows = await impl().due(now, 50);
|
||||
for (const r of rows) {
|
||||
try {
|
||||
if (r.step >= seq.length) { await impl().update(r.email, { stopped: 1 }); continue; }
|
||||
await sendStep(r.email, r.step);
|
||||
sent += 1;
|
||||
const next = r.step + 1;
|
||||
if (next >= seq.length) await impl().update(r.email, { step: next, stopped: 1 });
|
||||
else await impl().update(r.email, { step: next, nextAt: Math.max(now + 60000, r.started + seq[next].hours * 3600000) });
|
||||
} catch (e) {
|
||||
console.error('drip send', r.email, e.message);
|
||||
await impl().update(r.email, { nextAt: now + 6 * 3600000 }); // retry later, do not spin
|
||||
}
|
||||
}
|
||||
} finally { ticking = false; }
|
||||
return sent;
|
||||
}
|
||||
async function stats() { return impl().stats(); }
|
||||
|
||||
module.exports = { init, enqueue, tick, stats, sequence, setSequence, resetSequence, sendStep, unsubscribe, unsubUrl, isUnsubscribed, DEFAULT_SEQUENCE };
|
||||
@@ -0,0 +1,120 @@
|
||||
'use strict';
|
||||
// Country lookup for ad targeting, zero dependencies. Data: DB-IP "IP to Country
|
||||
// Lite" (CC BY 4.0, attribution "IP Geolocation by DB-IP" is shown on the site).
|
||||
// The server downloads the current month's CSV once, keeps it in DATA_DIR/geo/,
|
||||
// loads it into sorted range tables (IPv4 as uint32, IPv6 as BigInt) and answers
|
||||
// countryOf(ip) by binary search. Refreshes monthly.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const zlib = require('zlib');
|
||||
|
||||
let DATA_DIR = '.';
|
||||
let v4 = { start: [], end: [], cc: [] }, v6 = { start: [], end: [], cc: [] };
|
||||
let loaded = false, loadedFile = '';
|
||||
|
||||
// Tier lists (affiliate-marketing convention). Editable in Admin > Settings as
|
||||
// comma-separated ISO codes; tier 3 = everything else.
|
||||
const DEFAULT_TIER1 = 'US,CA,GB,AU,NZ,IE,DE,FR,NL,SE,NO,DK,FI,CH,AT,BE';
|
||||
const DEFAULT_TIER2 = 'IT,ES,PT,PL,CZ,HU,GR,RO,SK,SI,HR,BG,EE,LV,LT,LU,IS,MT,CY,JP,KR,SG,HK,TW,IL,AE,SA,QA,KW,BH,OM,ZA,BR,MX,AR,CL,CO,UY,CR,PA,MY,TH,TR,RU,UA,KZ';
|
||||
|
||||
function ip4(s) { const p = s.split('.'); if (p.length !== 4) return null; let n = 0; for (const x of p) { const v = Number(x); if (!/^\d{1,3}$/.test(x) || v > 255) return null; n = n * 256 + v; } return n; }
|
||||
function ip6(s) {
|
||||
try {
|
||||
let [head, tail] = s.split('::');
|
||||
const h = head ? head.split(':') : [], t = tail ? tail.split(':') : [];
|
||||
if (s.includes('::')) { while (h.length + t.length < 8) h.push('0'); }
|
||||
const parts = h.concat(t); if (parts.length !== 8) return null;
|
||||
let n = 0n; for (const p of parts) { if (!/^[0-9a-f]{0,4}$/i.test(p)) return null; n = (n << 16n) + BigInt(parseInt(p || '0', 16)); }
|
||||
return n;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
function find(tbl, x) {
|
||||
let lo = 0, hi = tbl.start.length - 1;
|
||||
while (lo <= hi) { const mid = (lo + hi) >> 1; if (tbl.start[mid] <= x) { if (x <= tbl.end[mid]) return tbl.cc[mid]; lo = mid + 1; } else hi = mid - 1; }
|
||||
return null;
|
||||
}
|
||||
// ISO-2 country code or null (private ranges, unknown, no data yet)
|
||||
function countryOf(ip) {
|
||||
if (!loaded || !ip) return null;
|
||||
let s = String(ip).trim().replace(/^::ffff:/i, '');
|
||||
let cc = null;
|
||||
if (s.includes('.')) { const n = ip4(s); cc = n == null ? null : find(v4, n); }
|
||||
else { const n = ip6(s); cc = n == null ? null : find(v6, n); }
|
||||
return cc && cc !== 'ZZ' ? cc : null; // ZZ = private/reserved: unknown
|
||||
}
|
||||
|
||||
function tierLists(cfg) {
|
||||
const parse = (v, def) => new Set(String(v || def).toUpperCase().split(/[\s,]+/).filter(Boolean));
|
||||
return { t1: parse(cfg && cfg.geoTier1, DEFAULT_TIER1), t2: parse(cfg && cfg.geoTier2, DEFAULT_TIER2) };
|
||||
}
|
||||
// '1' | '2' | '3' | null (unknown country never matches a restricted campaign)
|
||||
function tierOf(cc, cfg) {
|
||||
if (!cc) return null;
|
||||
const { t1, t2 } = tierLists(cfg);
|
||||
return t1.has(cc) ? '1' : t2.has(cc) ? '2' : '3';
|
||||
}
|
||||
|
||||
// ── data file: DATA_DIR/geo/dbip-country-lite-YYYY-MM.csv.gz ──
|
||||
function monthKey(d) { d = d || new Date(); return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0'); }
|
||||
function fileFor(mk) { return path.join(DATA_DIR, 'geo', 'dbip-country-lite-' + mk + '.csv.gz'); }
|
||||
function download(mk) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.join(DATA_DIR, 'geo'), { recursive: true });
|
||||
const tmp = fileFor(mk) + '.part';
|
||||
const req = https.get('https://download.db-ip.com/free/dbip-country-lite-' + mk + '.csv.gz', { headers: { 'User-Agent': 'LinkSpin/1.0 (+https://linkspin-test.saasy.top)' }, timeout: 60000 }, res => {
|
||||
if (res.statusCode !== 200) { res.resume(); return reject(new Error('HTTP ' + res.statusCode)); }
|
||||
const out = fs.createWriteStream(tmp);
|
||||
res.pipe(out); out.on('finish', () => { fs.renameSync(tmp, fileFor(mk)); resolve(fileFor(mk)); }); out.on('error', reject);
|
||||
});
|
||||
req.on('error', reject); req.on('timeout', () => { req.destroy(new Error('timeout')); });
|
||||
});
|
||||
}
|
||||
function loadFile(file) {
|
||||
const raw = zlib.gunzipSync(fs.readFileSync(file)).toString('utf8');
|
||||
const a4 = { start: [], end: [], cc: [] }, a6 = { start: [], end: [], cc: [] };
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line) continue;
|
||||
const c = line.indexOf(','), c2 = line.indexOf(',', c + 1);
|
||||
if (c < 0 || c2 < 0) continue;
|
||||
const s = line.slice(0, c), e = line.slice(c + 1, c2), cc = line.slice(c2 + 1).trim().toUpperCase();
|
||||
if (cc.length !== 2) continue;
|
||||
if (s.includes('.')) { const a = ip4(s), b = ip4(e); if (a != null && b != null) { a4.start.push(a); a4.end.push(b); a4.cc.push(cc); } }
|
||||
else { const a = ip6(s), b = ip6(e); if (a != null && b != null) { a6.start.push(a); a6.end.push(b); a6.cc.push(cc); } }
|
||||
}
|
||||
// DB-IP ships sorted; guard anyway
|
||||
const order = t => { const idx = t.start.map((_, i) => i).sort((i, j) => (t.start[i] < t.start[j] ? -1 : t.start[i] > t.start[j] ? 1 : 0)); return { start: idx.map(i => t.start[i]), end: idx.map(i => t.end[i]), cc: idx.map(i => t.cc[i]) }; };
|
||||
v4 = order(a4); v6 = order(a6); loaded = true; loadedFile = file;
|
||||
return { v4: v4.start.length, v6: v6.start.length };
|
||||
}
|
||||
// load the newest local file now; fetch this month's if missing (async, non-blocking)
|
||||
async function init(opts) {
|
||||
DATA_DIR = opts.dataDir;
|
||||
try {
|
||||
const dir = path.join(DATA_DIR, 'geo');
|
||||
const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => /^dbip-country-lite-\d{4}-\d{2}\.csv\.gz$/.test(f)).sort() : [];
|
||||
if (files.length) { const n = loadFile(path.join(dir, files[files.length - 1])); console.log('geo: loaded', files[files.length - 1], n.v4, 'v4 +', n.v6, 'v6 ranges'); }
|
||||
} catch (e) { console.error('geo: load failed', e.message); }
|
||||
refresh().catch(e => console.error('geo: refresh failed', e.message));
|
||||
}
|
||||
// this month's file: download if missing, then (re)load it; safe to call daily
|
||||
async function refresh() {
|
||||
const mk = monthKey();
|
||||
const f = fileFor(mk);
|
||||
if (!fs.existsSync(f)) {
|
||||
try { await download(mk); } catch (e) {
|
||||
// early in the month DB-IP may not have published yet: fall back to last month
|
||||
const d = new Date(); d.setUTCMonth(d.getUTCMonth() - 1); const prev = monthKey(d);
|
||||
if (!fs.existsSync(fileFor(prev))) await download(prev);
|
||||
}
|
||||
}
|
||||
const dir = path.join(DATA_DIR, 'geo');
|
||||
const files = fs.readdirSync(dir).filter(x => /^dbip-country-lite-\d{4}-\d{2}\.csv\.gz$/.test(x)).sort();
|
||||
const newest = path.join(dir, files[files.length - 1]);
|
||||
if (newest !== loadedFile) { const n = loadFile(newest); console.log('geo: loaded', files[files.length - 1], n.v4, 'v4 +', n.v6, 'v6 ranges'); }
|
||||
for (const old of files.slice(0, -2)) { try { fs.unlinkSync(path.join(dir, old)); } catch (e) {} }
|
||||
return { file: files[files.length - 1], ranges: v4.start.length + v6.start.length };
|
||||
}
|
||||
function status() { return { loaded, file: path.basename(loadedFile || ''), v4: v4.start.length, v6: v6.start.length }; }
|
||||
|
||||
module.exports = { init, refresh, countryOf, tierOf, tierLists, status, DEFAULT_TIER1, DEFAULT_TIER2 };
|
||||
@@ -0,0 +1,146 @@
|
||||
// Leaderboard + weekly/monthly referral contest (Marty, 2026-09-14).
|
||||
// Sales credit = tier-1 payouts on-chain: the direct sponsor of every package sold. A member's own linked
|
||||
// positions and second accounts do not count for them (only sales to other people's accounts). Periods run
|
||||
// on Central time: week = Monday 00:00 to Sunday 23:59, month = calendar month. Winners are recorded at
|
||||
// rollover (checked hourly), announced to Telegram, and credit prizes are granted automatically.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
let R = null; // { chain, accounts, ads, dataDir, siteConfig, notify(text), pushFeed(ev) }
|
||||
const TZ = 'America/Chicago';
|
||||
let cache = { at: 0, rows: null };
|
||||
const FILE = () => path.join(R.dataDir, 'leaderboard-winners.json');
|
||||
function init(refs) { R = refs; }
|
||||
|
||||
// Central-time helpers (no tz lib): shift by the zone offset at that instant
|
||||
function ctParts(ts) {
|
||||
const s = new Date(ts).toLocaleString('en-US', { timeZone: TZ, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', weekday: 'short' });
|
||||
const m = /(\w{3}), (\d{2})\/(\d{2})\/(\d{4}), (\d{2}):(\d{2})/.exec(s);
|
||||
return { wd: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(m[1]), y: +m[4], mo: +m[2], d: +m[3], h: +m[5] % 24, mi: +m[6] };
|
||||
}
|
||||
function ctMidnight(ts) { // the instant of 00:00 Central on the Central date of ts
|
||||
const p = ctParts(ts); const guess = Date.UTC(p.y, p.mo - 1, p.d, 5, 0, 0); // CDT = UTC-5; CST = UTC-6
|
||||
const q = ctParts(guess); return (q.h === 0 && q.d === p.d) ? guess : guess + 3600000;
|
||||
}
|
||||
function weekStart(ts) { const p = ctParts(ts); const mid = ctMidnight(ts); const back = (p.wd + 6) % 7; return mid - back * 86400000; }
|
||||
function monthStart(ts) { const p = ctParts(ts); return ctMidnight(Date.UTC(p.y, p.mo - 1, 1, 12)); }
|
||||
function periodBounds(period, ts) {
|
||||
const now = ts || Date.now();
|
||||
if (period === 'week') return { start: weekStart(now), end: now, label: 'This week' };
|
||||
if (period === 'lastweek') { const s = weekStart(now); return { start: weekStart(s - 1), end: s, label: 'Last week' }; }
|
||||
if (period === 'month') return { start: monthStart(now), end: now, label: 'This month' };
|
||||
if (period === 'lastmonth') { const s = monthStart(now); return { start: monthStart(s - 1), end: s, label: 'Last month' }; }
|
||||
return { start: 0, end: now, label: 'All time' };
|
||||
}
|
||||
|
||||
// member id -> account email (main ids + linked positions), refreshed every minute
|
||||
let idMap = { at: 0, map: {}, names: {} };
|
||||
async function memberMap() {
|
||||
if (Date.now() - idMap.at < 60000) return idMap;
|
||||
const map = {}, names = {};
|
||||
const list = await R.accounts.listAll(5000);
|
||||
for (const a of list) {
|
||||
if (a.memberId) { map[a.memberId] = a.email; names[a.email] = a.username ? '@' + a.username : 'member #' + a.memberId; }
|
||||
else names[a.email] = a.username ? '@' + a.username : a.email.replace(/@.*/, '') + '@';
|
||||
try { for (const p of await R.accounts.positions(a.email)) if (p.memberId) map[p.memberId] = a.email; } catch (e) {}
|
||||
}
|
||||
idMap = { at: Date.now(), map, names }; return idMap;
|
||||
}
|
||||
|
||||
const computeCache = {};
|
||||
async function compute(period) {
|
||||
const c = computeCache[period]; if (c && Date.now() - c.at < 30000) return c.val;
|
||||
const val = await computeRaw(period); computeCache[period] = { at: Date.now(), val }; return val;
|
||||
}
|
||||
async function computeRaw(period) {
|
||||
const { start, end, label } = periodBounds(period);
|
||||
const { map, names } = await memberMap();
|
||||
const price = {}; // tx -> cents
|
||||
for (const e of R.chain.recentEvents(1e9)) if (e.type === 'Purchase') price[e.tx + ':' + e.buyerId] = Number(e.priceCents || 0);
|
||||
const rows = {};
|
||||
for (const e of R.chain.recentEvents(1e9)) {
|
||||
if (e.type !== 'TierPaid' || e.tier !== 1 || e.ts < start || e.ts >= end) continue;
|
||||
const sponsor = map[e.recipientId], buyer = map[e.buyerId];
|
||||
if (!sponsor) continue;
|
||||
if (buyer && buyer === sponsor) continue; // own positions never count
|
||||
const r = rows[sponsor] = rows[sponsor] || { email: sponsor, name: names[sponsor], sales: 0, cents: 0, pol: 0n, buyers: new Set() };
|
||||
r.sales += 1; r.cents += price[e.tx + ':' + e.buyerId] || 0; r.pol += BigInt(e.amountWei || 0); if (buyer) r.buyers.add(buyer);
|
||||
}
|
||||
// sign-ups sponsored in the period (site-side), for the secondary column
|
||||
const joins = {};
|
||||
for (const a of await R.accounts.listAll(5000)) {
|
||||
if (!a.sponsorRef || a.created < start || a.created >= end) continue;
|
||||
let s = null; try { s = await R.accounts.sponsorOf(a.email); } catch (e) {}
|
||||
if (s && s.email !== a.email) joins[s.email] = (joins[s.email] || 0) + 1;
|
||||
}
|
||||
for (const [em, n] of Object.entries(joins)) { const r = rows[em] = rows[em] || { email: em, name: names[em] || em, sales: 0, cents: 0, pol: 0n, buyers: new Set() }; r.joins = n; }
|
||||
// the admin's own account (company placements, tank arrivals) is not a contestant
|
||||
if (R.adminEmail && rows[R.adminEmail]) delete rows[R.adminEmail];
|
||||
const out = Object.values(rows).map(r => ({ email: r.email, name: r.name, sales: r.sales, usd: r.cents / 100, pol: Number(r.pol / 10n ** 14n) / 10000, buyers: r.buyers.size, joins: r.joins || 0 }))
|
||||
.sort((a, b) => b.usd - a.usd || b.sales - a.sales || b.joins - a.joins);
|
||||
out.forEach((r, i) => { r.rank = i + 1; });
|
||||
return { period, label, start, end, rows: out };
|
||||
}
|
||||
async function view(period, meEmail) {
|
||||
const key = period || 'week';
|
||||
const r = await compute(key);
|
||||
const sc = R.siteConfig();
|
||||
const prize = prizeText(key.includes('month') ? 'month' : 'week', sc);
|
||||
const me = meEmail ? r.rows.find(x => x.email === meEmail) : null;
|
||||
return { period: r.period, label: r.label, start: r.start, end: r.end, prize: prize || '', top: r.rows.slice(0, 10).map(pub), me: me ? pub(me) : null, count: r.rows.length,
|
||||
winners: winners().slice(0, 6) };
|
||||
}
|
||||
const pub = r => ({ rank: r.rank, name: r.name, sales: r.sales, usd: r.usd, buyers: r.buyers, joins: r.joins });
|
||||
// "1000,500,250" -> [1000, 500, 250]: credits for 1st, 2nd, 3rd... (Marty: award the top X positions, 2026-09-14)
|
||||
const ladder = v => String(v || '').split(',').map(x => Math.round(Number(x)) || 0).filter(n => n > 0);
|
||||
const ORD = ['1st', '2nd', '3rd', '4th', '5th', '6th', '7th', '8th', '9th', '10th'];
|
||||
const ladderText = l => l.length ? l.map((n, i) => ORD[i] + ' ' + n.toLocaleString()).join(' · ') + ' credits' : '';
|
||||
function prizeText(kind, sc) { const t = kind === 'week' ? sc.leaderboardWeeklyPrize : sc.leaderboardMonthlyPrize; if (t) return t; return ladderText(ladder(kind === 'week' ? sc.leaderboardWeeklyCredits : sc.leaderboardMonthlyCredits)); }
|
||||
function winners() { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { return []; } }
|
||||
|
||||
// rollover: once a completed week/month has no winner recorded, record it, grant credits, announce
|
||||
async function rolloverTick() {
|
||||
const now = Date.now(); const sc = R.siteConfig(); const w = winners(); let changed = false;
|
||||
for (const kind of ['week', 'month']) {
|
||||
const cur = kind === 'week' ? weekStart(now) : monthStart(now);
|
||||
const prevStart = kind === 'week' ? weekStart(cur - 1) : monthStart(cur - 1);
|
||||
if (prevStart < Date.parse('2026-09-08T05:00:00Z')) continue; // contest starts with the week of Sep 8
|
||||
if (w.find(x => x.kind === kind && x.start === prevStart)) continue;
|
||||
const r = await compute(kind === 'week' ? 'lastweek' : 'lastmonth');
|
||||
const lad = ladder(kind === 'week' ? sc.leaderboardWeeklyCredits : sc.leaderboardMonthlyCredits);
|
||||
const top = r.rows.filter(x => x.sales > 0).slice(0, Math.max(3, lad.length));
|
||||
const prize = prizeText(kind, sc);
|
||||
const rec = { kind, start: prevStart, end: cur, prize: prize || '', ladder: lad, top: top.map(pub), granted: [], at: now };
|
||||
w.unshift(rec); changed = true;
|
||||
for (let i = 0; i < lad.length && i < top.length; i++) { try { await R.ads.addEarned(top[i].email, lad[i]); rec.granted.push({ rank: i + 1, name: top[i].name, credits: lad[i] }); } catch (e) {} }
|
||||
if (top[0]) {
|
||||
const when = new Date(prevStart).toLocaleDateString('en-US', { timeZone: TZ, month: 'short', day: 'numeric' });
|
||||
const line = '\u{1F3C6} <b>LinkSpin</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 : '')) + '\nlinkspin-test.saasy.top/leaderboard';
|
||||
try { await R.notify(line); } catch (e) {}
|
||||
try { R.pushFeed({ type: 'Contest', kind, winner: top[0].name, ts: now }); } catch (e) {}
|
||||
}
|
||||
}
|
||||
if (changed) fs.writeFileSync(FILE(), JSON.stringify(w.slice(0, 60), null, 1));
|
||||
}
|
||||
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
async function renderPage() {
|
||||
const week = await view('week'), month = await view('month'), all = await view('all');
|
||||
const sc = R.siteConfig();
|
||||
const table = v => '<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 LinkSpin 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 | LinkSpin</title><meta name="description" content="' + esc(desc) + '"><link rel="canonical" href="https://linkspin-test.saasy.top/leaderboard"><meta property="og:title" content="LinkSpin leaderboard"><meta property="og:description" content="' + esc(desc) + '"><meta property="og:image" content="https://linkspin-test.saasy.top/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,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,47 @@
|
||||
// Outbound mail via SendGrid v3 (domain-authenticated linkspin-test.saasy.top).
|
||||
// Key sources: SENDGRID_KEY env, else DATA_DIR/sendgrid.key in the volume.
|
||||
// No key = email sign-in stays feature-flagged off in production.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
|
||||
let DATA_DIR = null;
|
||||
const FROM = { email: 'no-reply@linkspin-test.saasy.top', name: 'LinkSpin' };
|
||||
|
||||
function init(opts) { DATA_DIR = opts.dataDir; }
|
||||
function key() {
|
||||
if (process.env.SENDGRID_KEY) return process.env.SENDGRID_KEY.trim();
|
||||
try { return fs.readFileSync(path.join(DATA_DIR, 'sendgrid.key'), 'utf8').trim(); } catch (e) { return ''; }
|
||||
}
|
||||
function hasKey() { return !!key(); }
|
||||
|
||||
function send(to, subject, text) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = JSON.stringify({
|
||||
personalizations: [{ to: [{ email: to }] }],
|
||||
from: FROM,
|
||||
subject,
|
||||
content: [{ type: 'text/plain', value: text }]
|
||||
});
|
||||
const req = https.request({ hostname: 'api.sendgrid.com', path: '/v3/mail/send', method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + key(), 'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body) }, timeout: 15000 },
|
||||
res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => res.statusCode < 300 ? resolve(true) : reject(new Error('sendgrid ' + res.statusCode + ': ' + d.slice(0, 200))));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => req.destroy(new Error('sendgrid timeout')));
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
function sendCode(to, code) {
|
||||
return send(to, code + ' is your LinkSpin sign-in code',
|
||||
'Your sign-in code is: ' + code + '\n\n'
|
||||
+ 'It works for 15 minutes. If you did not request it, ignore this email.\n\n'
|
||||
+ 'LinkSpin\nAdvertise and earn instantly. Locked in code, not promises.\nhttps://linkspin-test.saasy.top');
|
||||
}
|
||||
|
||||
module.exports = { init, hasKey, send, sendCode };
|
||||
@@ -0,0 +1,181 @@
|
||||
// Sponsor → downline messages: team comms delivered to members' on-site inbox
|
||||
// (and by email). Distinct from solo ADS (which are paid and earn credits).
|
||||
// Dual-mode: MySQL when DATABASE_URL is set, else a JSON file in the volume.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('./db');
|
||||
|
||||
let DATA_DIR = null;
|
||||
const J = {
|
||||
db: null,
|
||||
FILE: () => path.join(DATA_DIR, 'sponsor-messages.json'),
|
||||
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = { nextId: 1, items: [] }; } },
|
||||
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
|
||||
};
|
||||
function init(opts) { DATA_DIR = opts.dataDir; }
|
||||
const DAY = 86400000;
|
||||
|
||||
// has this sponsor already broadcast within the last 24h? (1/day cap)
|
||||
async function lastBroadcastAt(fromEmail) {
|
||||
const e = String(fromEmail || '').toLowerCase();
|
||||
if (db.enabled()) {
|
||||
const r = await db.q("SELECT MAX(sent) m FROM sponsor_messages WHERE from_email=? AND kind='broadcast'", [e]);
|
||||
return r.length && r[0].m ? Number(r[0].m) : 0;
|
||||
}
|
||||
if (!J.db) J.load();
|
||||
const mine = J.db.items.filter(i => i.fromEmail === e && (i.kind || 'broadcast') === 'broadcast').map(i => i.sent);
|
||||
return mine.length ? Math.max(...mine) : 0;
|
||||
}
|
||||
// deliver one message to many recipients (already-resolved emails). Returns count.
|
||||
async function deliver(fromMember, fromEmail, recipients, subject, body) {
|
||||
const now = Date.now();
|
||||
let n = 0;
|
||||
if (db.enabled()) {
|
||||
for (const to of recipients) {
|
||||
await db.q("INSERT INTO sponsor_messages (from_member,from_email,to_email,subject,body,sent,kind) VALUES (?,?,?,?,?,?,'broadcast')",
|
||||
[fromMember || 0, fromEmail, to, subject, body, now]);
|
||||
n++;
|
||||
}
|
||||
} else {
|
||||
if (!J.db) J.load();
|
||||
for (const to of recipients) {
|
||||
J.db.items.push({ id: J.db.nextId++, kind: 'broadcast', fromMember: fromMember || 0, fromEmail, toEmail: to, subject, body, sent: now, readTs: 0 });
|
||||
n++;
|
||||
}
|
||||
J.save();
|
||||
}
|
||||
return n;
|
||||
}
|
||||
const isBroadcast = i => (i.kind || 'broadcast') === 'broadcast';
|
||||
const isChat = i => i.kind === 'chat';
|
||||
async function inbox(email) {
|
||||
const e = String(email || '').toLowerCase();
|
||||
if (db.enabled()) {
|
||||
const rows = await db.q(`SELECT id, from_member, subject, body, sent, read_ts FROM sponsor_messages
|
||||
WHERE to_email=? AND kind='broadcast' ORDER BY sent DESC LIMIT 100`, [e]);
|
||||
return rows.map(r => ({ id: r.id, fromMember: r.from_member, subject: r.subject, body: r.body,
|
||||
sent: Number(r.sent), read: !!r.read_ts }));
|
||||
}
|
||||
if (!J.db) J.load();
|
||||
return J.db.items.filter(i => i.toEmail === e && isBroadcast(i)).sort((a, b) => b.sent - a.sent).slice(0, 100)
|
||||
.map(i => ({ id: i.id, fromMember: i.fromMember, subject: i.subject, body: i.body, sent: i.sent, read: !!i.readTs }));
|
||||
}
|
||||
|
||||
// ── two-way sponsor CHAT (kind='chat'), plain-text 1:1 threads ──
|
||||
async function sendChat(fromMember, fromEmail, toEmail, body) {
|
||||
const now = Date.now();
|
||||
const from = String(fromEmail || '').toLowerCase(), to = String(toEmail || '').toLowerCase();
|
||||
if (db.enabled()) {
|
||||
const r = await db.q("INSERT INTO sponsor_messages (from_member,from_email,to_email,subject,body,sent,kind) VALUES (?,?,?,'',?,?,'chat')",
|
||||
[fromMember || 0, from, to, body, now]);
|
||||
return { id: r.insertId, sent: now };
|
||||
}
|
||||
if (!J.db) J.load();
|
||||
const id = J.db.nextId++;
|
||||
J.db.items.push({ id, kind: 'chat', fromMember: fromMember || 0, fromEmail: from, toEmail: to, subject: '', body, sent: now, readTs: 0 });
|
||||
J.save();
|
||||
return { id, sent: now };
|
||||
}
|
||||
// messages between two members (both directions), id > afterId, oldest→newest
|
||||
// last message from one member to another, any kind (0 if never)
|
||||
async function lastFrom(fromEmail, toEmail) {
|
||||
const f = String(fromEmail || '').toLowerCase(), t = String(toEmail || '').toLowerCase();
|
||||
if (db.enabled()) { const r = await db.q('SELECT MAX(sent) s FROM sponsor_messages WHERE from_email=? AND to_email=?', [f, t]); return Number((r[0] && r[0].s) || 0); }
|
||||
if (!J.db) J.load();
|
||||
return J.db.items.filter(i => i.fromEmail === f && i.toEmail === t).reduce((m, i) => Math.max(m, i.sent || 0), 0);
|
||||
}
|
||||
async function thread(aEmail, bEmail, afterId = 0, limit = 300) {
|
||||
const a = String(aEmail || '').toLowerCase(), b = String(bEmail || '').toLowerCase();
|
||||
if (db.enabled()) {
|
||||
const rows = await db.q(`SELECT id, from_member, from_email, body, sent, read_ts FROM sponsor_messages
|
||||
WHERE kind='chat' AND id>? AND ((from_email=? AND to_email=?) OR (from_email=? AND to_email=?))
|
||||
ORDER BY id ASC LIMIT ?`, [Number(afterId) || 0, a, b, b, a, limit]);
|
||||
return rows.map(r => ({ id: r.id, fromMember: r.from_member, fromEmail: r.from_email, body: r.body, sent: Number(r.sent), read: !!r.read_ts }));
|
||||
}
|
||||
if (!J.db) J.load();
|
||||
return J.db.items.filter(i => isChat(i) && i.id > (Number(afterId) || 0)
|
||||
&& ((i.fromEmail === a && i.toEmail === b) || (i.fromEmail === b && i.toEmail === a)))
|
||||
.sort((x, y) => x.id - y.id).slice(0, limit)
|
||||
.map(i => ({ id: i.id, fromMember: i.fromMember, fromEmail: i.fromEmail, body: i.body, sent: i.sent, read: !!i.readTs }));
|
||||
}
|
||||
// mark every chat message FROM other → email as read
|
||||
async function markChatRead(email, otherEmail) {
|
||||
const e = String(email || '').toLowerCase(), o = String(otherEmail || '').toLowerCase();
|
||||
if (db.enabled()) {
|
||||
await db.q("UPDATE sponsor_messages SET read_ts=? WHERE kind='chat' AND to_email=? AND from_email=? AND read_ts IS NULL", [Date.now(), e, o]);
|
||||
return { ok: true };
|
||||
}
|
||||
if (!J.db) J.load();
|
||||
let touched = false;
|
||||
for (const i of J.db.items) if (isChat(i) && i.toEmail === e && i.fromEmail === o && !i.readTs) { i.readTs = Date.now(); touched = true; }
|
||||
if (touched) J.save();
|
||||
return { ok: true };
|
||||
}
|
||||
async function chatUnread(email) {
|
||||
const e = String(email || '').toLowerCase();
|
||||
if (db.enabled()) {
|
||||
const r = await db.q("SELECT COUNT(*) n FROM sponsor_messages WHERE kind='chat' AND to_email=? AND read_ts IS NULL", [e]);
|
||||
return r[0].n;
|
||||
}
|
||||
if (!J.db) J.load();
|
||||
return J.db.items.filter(i => isChat(i) && i.toEmail === e && !i.readTs).length;
|
||||
}
|
||||
// one row per conversation partner: newest message + my unread count from them
|
||||
async function threadList(email) {
|
||||
const e = String(email || '').toLowerCase();
|
||||
let rows;
|
||||
if (db.enabled()) {
|
||||
rows = (await db.q(`SELECT from_email, to_email, body, sent, read_ts FROM sponsor_messages
|
||||
WHERE kind='chat' AND (from_email=? OR to_email=?) ORDER BY sent DESC LIMIT 800`, [e, e]))
|
||||
.map(r => ({ fromEmail: r.from_email, toEmail: r.to_email, body: r.body, sent: Number(r.sent), read: !!r.read_ts }));
|
||||
} else {
|
||||
if (!J.db) J.load();
|
||||
rows = J.db.items.filter(i => isChat(i) && (i.fromEmail === e || i.toEmail === e))
|
||||
.sort((a, b) => b.sent - a.sent)
|
||||
.map(i => ({ fromEmail: i.fromEmail, toEmail: i.toEmail, body: i.body, sent: i.sent, read: !!i.readTs }));
|
||||
}
|
||||
const byOther = new Map();
|
||||
for (const r of rows) {
|
||||
const other = r.fromEmail === e ? r.toEmail : r.fromEmail;
|
||||
if (!byOther.has(other)) byOther.set(other, { email: other, last: { body: r.body, sent: r.sent, fromMe: r.fromEmail === e }, unread: 0 });
|
||||
if (r.toEmail === e && !r.read) byOther.get(other).unread++;
|
||||
}
|
||||
return [...byOther.values()];
|
||||
}
|
||||
async function unreadCount(email) {
|
||||
const e = String(email || '').toLowerCase();
|
||||
if (db.enabled()) {
|
||||
const r = await db.q("SELECT COUNT(*) n FROM sponsor_messages WHERE to_email=? AND kind='broadcast' AND read_ts IS NULL", [e]);
|
||||
return r[0].n;
|
||||
}
|
||||
if (!J.db) J.load();
|
||||
return J.db.items.filter(i => i.toEmail === e && isBroadcast(i) && !i.readTs).length;
|
||||
}
|
||||
// the newest unread BROADCAST (for the sign-in modal; chat never pops the modal)
|
||||
async function newestUnread(email) {
|
||||
const e = String(email || '').toLowerCase();
|
||||
if (db.enabled()) {
|
||||
const rows = await db.q(`SELECT id, from_member, subject, body, sent FROM sponsor_messages
|
||||
WHERE to_email=? AND kind='broadcast' AND read_ts IS NULL ORDER BY sent DESC LIMIT 1`, [e]);
|
||||
if (!rows.length) return null;
|
||||
const r = rows[0];
|
||||
return { id: r.id, fromMember: r.from_member, subject: r.subject, body: r.body, sent: Number(r.sent) };
|
||||
}
|
||||
if (!J.db) J.load();
|
||||
const u = J.db.items.filter(i => i.toEmail === e && isBroadcast(i) && !i.readTs).sort((a, b) => b.sent - a.sent)[0];
|
||||
return u ? { id: u.id, fromMember: u.fromMember, subject: u.subject, body: u.body, sent: u.sent } : null;
|
||||
}
|
||||
async function markRead(email, id) {
|
||||
const e = String(email || '').toLowerCase();
|
||||
if (db.enabled()) {
|
||||
await db.q('UPDATE sponsor_messages SET read_ts=? WHERE id=? AND to_email=? AND read_ts IS NULL', [Date.now(), Number(id), e]);
|
||||
return { ok: true };
|
||||
}
|
||||
if (!J.db) J.load();
|
||||
const i = J.db.items.find(x => x.id === Number(id) && x.toEmail === e);
|
||||
if (i && !i.readTs) { i.readTs = Date.now(); J.save(); }
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
module.exports = { init, lastFrom, lastBroadcastAt, deliver, inbox, unreadCount, newestUnread, markRead,
|
||||
sendChat, thread, markChatRead, chatUnread, threadList };
|
||||
@@ -0,0 +1,125 @@
|
||||
// NAS syndication — pushes IAP campaigns out to Network Ad Space (Marty's own
|
||||
// EvolutionScript platform; no source, so its MySQL DB IS the API). Mirrors the
|
||||
// CTB Rewards direct-write pattern: INSERT payments then sponsorads with
|
||||
// approved:1 (bypasses NAS moderation), and reads `remaining` back to reconcile
|
||||
// spend into IAP's unified credit pool.
|
||||
//
|
||||
// KNOWN NAS TRUTHS (measured across RM Circle + CTB, 1600+ ads):
|
||||
// - sponsorads.remaining counts DOWN: served = assigned - remaining.
|
||||
// - pid 1 = text, pid 2 = banner ONLY; banner size lives in width/height.
|
||||
// - hits = clicks (not impressions). catid 5 = Cryptocurrencies.
|
||||
// - NAS drifts its own counters upward post-insert → clamp served to assigned.
|
||||
//
|
||||
// FEATURE-FLAGGED: inert unless NAS_DB_HOST/USER/PASSWORD/NAME are all set.
|
||||
// Nothing here runs (no connection, no writes) when the flag is off.
|
||||
const crypto = require('crypto');
|
||||
|
||||
let pool = null;
|
||||
function enabled() {
|
||||
return !!(process.env.NAS_DB_HOST && process.env.NAS_DB_USER
|
||||
&& process.env.NAS_DB_PASSWORD && process.env.NAS_DB_NAME);
|
||||
}
|
||||
function nasPool() {
|
||||
if (pool) return pool;
|
||||
const mysql = require('mysql2/promise');
|
||||
pool = mysql.createPool({
|
||||
host: process.env.NAS_DB_HOST,
|
||||
port: Number(process.env.NAS_DB_PORT || 3306),
|
||||
user: process.env.NAS_DB_USER,
|
||||
password: process.env.NAS_DB_PASSWORD,
|
||||
database: process.env.NAS_DB_NAME,
|
||||
charset: 'latin1', // EvolutionScript is latin1
|
||||
connectionLimit: 3,
|
||||
connectTimeout: 10000
|
||||
});
|
||||
return pool;
|
||||
}
|
||||
async function q(sql, args) { const [r] = await nasPool().query(sql, args); return r; }
|
||||
|
||||
const CATID_CRYPTO = Number(process.env.NAS_CATID || 5);
|
||||
// how many NAS impressions one IAP credit buys, per format. IAP charges credits
|
||||
// on its own surfaces at these same rates, so NAS delivery draws the same pool.
|
||||
function impressionsPerCredit(type) {
|
||||
return type === 'banner' ? 5 : type === 'text' ? 10 : type === 'video' ? 0 : 0;
|
||||
}
|
||||
function nasKind(type) {
|
||||
// pid encodes text(1)/banner(2); adtype is a separate small int — 1 is the
|
||||
// value the overwhelming majority of live NAS rows use for both formats.
|
||||
const adtype = Number(process.env.NAS_ADTYPE || 1);
|
||||
if (type === 'banner') return { pid: 2, adtype };
|
||||
if (type === 'text') return { pid: 1, adtype };
|
||||
return null; // only banner/text syndicate to NAS in v1 (login/solo/video are IAP-native)
|
||||
}
|
||||
|
||||
// push one IAP campaign into NAS. `c` is a pubC-shaped campaign. Returns
|
||||
// { nasAdId, assigned } or { skipped } / throws on a real DB error.
|
||||
async function pushCampaign(c, opts = {}) {
|
||||
if (!enabled()) return { skipped: 'flag-off' };
|
||||
const kind = nasKind(c.type);
|
||||
if (!kind) return { skipped: 'type' };
|
||||
const budgetLeft = c.budget - (c.spent || 0);
|
||||
const window = c.dailyCap ? Math.min(c.dailyCap, budgetLeft) : budgetLeft; // capped campaigns start with one day's allowance
|
||||
const assigned = Math.max(1, Math.floor(window * impressionsPerCredit(c.type)));
|
||||
const token = 'iap_' + crypto.randomBytes(8).toString('hex'); // manage token = Username
|
||||
const now = new Date();
|
||||
const days = Number(opts.days || 30);
|
||||
const edate = new Date(now.getTime() + days * 86400000);
|
||||
const payref = 'iap_campaign:' + c.id;
|
||||
// payments first (MyISAM, no txn) — hand-rollback the row if sponsorads fails
|
||||
const pay = await q(
|
||||
'INSERT INTO payments (Username, Amount, Currency_code, status, Date, pay_address) VALUES (?,?,?,1,?,?)',
|
||||
[token, 0, 'IAP_CREDIT', now, payref]);
|
||||
try {
|
||||
const ad = await q(
|
||||
`INSERT INTO sponsorads
|
||||
(Username, Subject, Body, WebsiteURL, assigned, remaining, hits, approved, Date, adtype,
|
||||
Name, Email, PaymentDetails, EDate, sp, width, height, BannerURL, pid, ref_by, catid)
|
||||
VALUES (?,?,?,?,?,?,0,1,?,?,?,?,?,?,'',?,?,?,?,0,?)`,
|
||||
// clicks route through our redirect so they count and carry the network site as the referrer
|
||||
[token, c.title || null, c.type === 'text' ? (c.body || null) : null, 'https://linkspin-test.saasy.top/api/ads/click/' + c.id,
|
||||
assigned, assigned, now, kind.adtype,
|
||||
opts.name || 'LinkSpin member', opts.email || 'ads@linkspin-test.saasy.top',
|
||||
'LinkSpin campaign #' + c.id, edate,
|
||||
c.width || '', c.height || '', c.type === 'banner' ? c.imageUrl : null, kind.pid, CATID_CRYPTO]);
|
||||
return { nasAdId: ad.insertId, manageToken: token, assigned };
|
||||
} catch (e) {
|
||||
try { await q('DELETE FROM payments WHERE ID=?', [pay.insertId]); } catch (e2) {}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// read served count for a syndicated ad (assigned - remaining, clamped ≥0 and
|
||||
// ≤ assigned because NAS drifts counters upward post-insert).
|
||||
async function readServed(nasAdId) {
|
||||
if (!enabled()) return null;
|
||||
const rows = await q('SELECT assigned, remaining, hits FROM sponsorads WHERE ID=?', [Number(nasAdId)]);
|
||||
if (!rows.length) return null;
|
||||
const assigned = Number(rows[0].assigned) || 0;
|
||||
const remaining = Number(rows[0].remaining) || 0;
|
||||
const served = Math.max(0, Math.min(assigned, assigned - remaining));
|
||||
return { assigned, remaining, served, clicks: Number(rows[0].hits) || 0 };
|
||||
}
|
||||
|
||||
// stop a syndicated ad (budget spent, paused, or expired) — remaining:0 halts serving.
|
||||
async function deactivate(nasAdId) {
|
||||
if (!enabled()) return { skipped: 'flag-off' };
|
||||
await q('UPDATE sponsorads SET remaining=0, EDate=NOW() WHERE ID=?', [Number(nasAdId)]);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// top up a syndicated ad with more impressions (buy-more-views / reactivate).
|
||||
// point an existing NAS ad at our click redirect (migration for rows pushed before 2026-09-10)
|
||||
async function setClickUrl(nasAdId, campaignId) {
|
||||
if (!enabled()) return { skipped: 'flag-off' };
|
||||
await q('UPDATE sponsorads SET WebsiteURL=? WHERE ID=?', ['https://linkspin-test.saasy.top/api/ads/click/' + Number(campaignId), Number(nasAdId)]);
|
||||
return { ok: true };
|
||||
}
|
||||
async function topUp(nasAdId, addImpressions, days) {
|
||||
if (!enabled()) return { skipped: 'flag-off' };
|
||||
await q(`UPDATE sponsorads SET assigned=assigned+?, remaining=remaining+?, approved=1,
|
||||
EDate=DATE_ADD(NOW(), INTERVAL ? DAY) WHERE ID=?`,
|
||||
[Number(addImpressions), Number(addImpressions), Number(days || 30), Number(nasAdId)]);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
module.exports = { setClickUrl, enabled, pushCampaign, readServed, deactivate, topUp, impressionsPerCredit, nasKind };
|
||||
@@ -0,0 +1,563 @@
|
||||
{
|
||||
"name": "linkspin-site",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "linkspin-site",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"ethers": "^6.13.0",
|
||||
"mysql2": "^3.11.0",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@adraffy/ens-normalize": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz",
|
||||
"integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
|
||||
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz",
|
||||
"integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aes-js": {
|
||||
"version": "4.0.0-beta.5",
|
||||
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
|
||||
"integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ethers": {
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz",
|
||||
"integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/ethers-io/"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.buymeacoffee.com/ricmoo"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adraffy/ens-normalize": "1.11.1",
|
||||
"@noble/curves": "1.2.0",
|
||||
"@noble/hashes": "1.3.2",
|
||||
"@types/node": "22.7.5",
|
||||
"aes-js": "4.0.0-beta.5",
|
||||
"tslib": "2.7.0",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ethers/node_modules/@types/node": {
|
||||
"version": "22.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
|
||||
"integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ethers/node_modules/undici-types": {
|
||||
"version": "6.19.8",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-property": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru.min": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz",
|
||||
"integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=1.30.0",
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.24.3",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.3.tgz",
|
||||
"integrity": "sha512-OKfWHkMAg9v06neq8FmSyhbxPQKABN9PAW5G9/bDTXzJBO5xXtkKL0V27vju7HQWk9UD4Od5BZsvCtBTB1CPEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"long": "^5.3.2",
|
||||
"lru.min": "^1.1.4",
|
||||
"named-placeholders": "^1.1.6",
|
||||
"sql-escaper": "^1.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/named-placeholders": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lru.min": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sql-escaper": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
|
||||
"integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=2.0.0",
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
|
||||
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "linkspin-site",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "LinkSpin membership advertising site - immutable on-chain settlement, transparent ledger.",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js",
|
||||
"qa": "bash qa/run.sh all",
|
||||
"qa:public": "bash qa/run.sh public",
|
||||
"qa:member": "bash qa/run.sh member",
|
||||
"qa:earn": "bash qa/run.sh earn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"mysql2": "^3.11.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"ethers": "^6.13.0"
|
||||
}
|
||||
}
|
||||
@@ -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,74 @@
|
||||
// Partner promo codes (Marty, 2026-09-12): a reusable code per partner site owner that gives
|
||||
// members who redeem it free ad credits (earned-grade) on top of whatever they already get.
|
||||
// Redeemed automatically when someone joins through a link carrying ?promo=CODE, or typed into
|
||||
// the dashboard. One redemption per code per account; every redemption is logged; optional
|
||||
// cap (max uses) and expiry; codes can be switched off. Storage: MySQL promo_codes +
|
||||
// promo_redemptions, or DATA_DIR/promos.json.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('./db');
|
||||
let DATA_DIR = null;
|
||||
const norm = c => String(c || '').trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '').slice(0, 24);
|
||||
|
||||
const J = {
|
||||
db: { v: 1, codes: {}, redemptions: [] },
|
||||
FILE() { return path.join(DATA_DIR, 'promos.json'); },
|
||||
load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} },
|
||||
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} },
|
||||
async get(code) { return this.db.codes[code] || null; },
|
||||
async list() { return Object.values(this.db.codes).sort((a, b) => b.created - a.created); },
|
||||
async put(c) { this.db.codes[c.code] = Object.assign(this.db.codes[c.code] || {}, c); this.save(); return this.db.codes[c.code]; },
|
||||
async uses(code) { return this.db.redemptions.filter(r => r.code === code).length; },
|
||||
async redeemed(code, email) { return this.db.redemptions.some(r => r.code === code && r.email === email); },
|
||||
async addRedemption(r) { this.db.redemptions.push(r); this.save(); },
|
||||
async redemptions(code, n) { return this.db.redemptions.filter(r => !code || r.code === code).slice(-(n || 200)).reverse(); }
|
||||
};
|
||||
const D = {
|
||||
async get(code) { const r = await db.q('SELECT * FROM promo_codes WHERE code=?', [code]); return r[0] ? row(r[0]) : null; },
|
||||
async list() { return (await db.q('SELECT * FROM promo_codes ORDER BY created DESC')).map(row); },
|
||||
async put(c) {
|
||||
await db.q('INSERT INTO promo_codes (code,credits,partner,note,max_uses,expires,active,created,funder) VALUES (?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE funder=VALUES(funder), credits=VALUES(credits), partner=VALUES(partner), note=VALUES(note), max_uses=VALUES(max_uses), expires=VALUES(expires), active=VALUES(active)',
|
||||
[c.code, c.credits, c.partner || null, c.note || null, c.maxUses || 0, c.expires || 0, c.active ? 1 : 0, c.created || Date.now(), c.funder || null]);
|
||||
return this.get(c.code);
|
||||
},
|
||||
async uses(code) { const r = await db.q('SELECT COUNT(*) n FROM promo_redemptions WHERE code=?', [code]); return Number(r[0].n); },
|
||||
async redeemed(code, email) { const r = await db.q('SELECT 1 FROM promo_redemptions WHERE code=? AND email=?', [code, email]); return r.length > 0; },
|
||||
async addRedemption(r) { await db.q('INSERT IGNORE INTO promo_redemptions (code,email,credits,via,ts) VALUES (?,?,?,?,?)', [r.code, r.email, r.credits, r.via, r.ts]); },
|
||||
async redemptions(code, n) { const rows = code ? await db.q('SELECT * FROM promo_redemptions WHERE code=? ORDER BY ts DESC LIMIT ?', [code, Number(n) || 200]) : await db.q('SELECT * FROM promo_redemptions ORDER BY ts DESC LIMIT ?', [Number(n) || 200]); return rows.map(r => ({ code: r.code, email: r.email, credits: Number(r.credits), via: r.via, ts: Number(r.ts) })); }
|
||||
};
|
||||
const row = r => ({ code: r.code, funder: r.funder || null, credits: Number(r.credits), partner: r.partner || '', note: r.note || '', maxUses: Number(r.max_uses) || 0, expires: Number(r.expires) || 0, active: !!Number(r.active), created: Number(r.created) });
|
||||
const impl = () => db.enabled() ? D : J;
|
||||
|
||||
function init(opts) { DATA_DIR = opts.dataDir; if (!db.enabled()) J.load(); }
|
||||
async function create(c) {
|
||||
const code = norm(c.code); if (!code || code.length < 3) return { error: 'Code must be 3 to 24 letters or numbers.' };
|
||||
const credits = Math.floor(Number(c.credits)); if (!(credits > 0) || credits > 100000) return { error: 'Credits must be between 1 and 100,000.' };
|
||||
const cur = await impl().get(code);
|
||||
const saved = await impl().put({ code, credits, funder: c.funder ? String(c.funder).toLowerCase() : (cur && cur.funder) || null, partner: String(c.partner || '').slice(0, 80), note: String(c.note || '').slice(0, 200), maxUses: Math.max(0, Math.floor(Number(c.maxUses) || 0)), expires: c.expires ? Number(new Date(c.expires)) || 0 : 0, active: c.active !== false && c.active !== 0 && c.active !== '0', created: cur ? cur.created : Date.now() });
|
||||
return { ok: true, code: saved };
|
||||
}
|
||||
async function setActive(code, active) { const c = await impl().get(norm(code)); if (!c) return { error: 'No such code.' }; await impl().put(Object.assign(c, { active: !!active })); return { ok: true }; }
|
||||
// why a code cannot be used right now, or null when it can (email optional: skips the per-account check)
|
||||
async function check(code, email) {
|
||||
const k = norm(code); if (!k) return { error: 'Enter a promo code.' };
|
||||
const c = await impl().get(k); if (!c || !c.active) return { error: 'That promo code is not valid.' };
|
||||
if (c.expires && Date.now() > c.expires) return { error: 'That promo code has expired.' };
|
||||
if (email && await impl().redeemed(k, email)) return { error: 'You already used that promo code.' };
|
||||
if (c.maxUses && (await impl().uses(k)) >= c.maxUses) return { error: 'That promo code has been fully redeemed.' };
|
||||
return null;
|
||||
}
|
||||
// redeem for an account; the caller adds the credits. via = 'link' | 'dashboard'
|
||||
async function redeem(code, email, via) {
|
||||
const k = norm(code); const e = String(email || '').toLowerCase();
|
||||
const bad = await check(k, e); if (bad) return bad;
|
||||
const c = await impl().get(k);
|
||||
await impl().addRedemption({ code: k, email: e, credits: c.credits, via: via || 'dashboard', ts: Date.now() });
|
||||
return { ok: true, credits: c.credits, code: k, partner: c.partner, funder: c.funder || null };
|
||||
}
|
||||
async function adminView() {
|
||||
const codes = await impl().list();
|
||||
for (const c of codes) c.uses = await impl().uses(c.code);
|
||||
return { codes, recent: await impl().redemptions(null, 100) };
|
||||
}
|
||||
async function byFunder(email) { return (await impl().list()).filter(c => c.funder === String(email || '').toLowerCase()); }
|
||||
module.exports = { init, create, setActive, check, redeem, adminView, norm, byFunder };
|
||||
@@ -0,0 +1,492 @@
|
||||
<!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 | LinkSpin</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">LinkSpin</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="LinkSpin" 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 LinkSpin 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"><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>linkspin-test.saasy.top/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">linkspin-test.saasy.top/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@linkspin-test.saasy.top, 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 LinkSpin 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">linkspin-test.saasy.top/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>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://linkspin-test.saasy.top">{{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=20260914a"></script>
|
||||
<script src="/assets/admin.js?v=20260915a"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,843 @@
|
||||
// Admin portal: email-code sign-in (allowlisted to ADMIN_EMAIL on the server),
|
||||
// house ads that cost nothing, every campaign, members, reports, settings.
|
||||
(function () {
|
||||
const $ = IAP.$;
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
async function api(path, body, method) {
|
||||
const opts = { method: method || (body === undefined ? 'GET' : 'POST'), headers: {} };
|
||||
if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
|
||||
const r = await (await fetch(path, opts)).json();
|
||||
if (r.error) throw new Error(r.error === 'auth' ? 'Session expired. Sign in again.' : r.error);
|
||||
return r;
|
||||
}
|
||||
function busy(btn, fn) {
|
||||
return async (...a) => {
|
||||
if (btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
try { await fn(...a); } catch (e) { IAP.status(e.message || 'Something went wrong.', 'bad'); }
|
||||
finally { btn.disabled = false; }
|
||||
};
|
||||
}
|
||||
const when = ts => ts ? new Date(Number(ts)).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
|
||||
let rates = {}, sizes = [], houseOwner = 'house@linkspin-test.saasy.top';
|
||||
|
||||
// ── sign-in ──
|
||||
$('adSend').addEventListener('click', busy($('adSend'), async () => {
|
||||
$('adErr').hidden = true;
|
||||
const r = await api('/api/admin/auth/start', { email: $('adEmail').value });
|
||||
$('adCodeRow').hidden = false; $('adVerify').hidden = false;
|
||||
if (r.devCode) $('adCode').value = r.devCode;
|
||||
IAP.status(r.sent ? 'Code sent. Check your inbox.' : 'Dev mode: code filled in.', 'ok');
|
||||
$('adCode').focus();
|
||||
}));
|
||||
$('adVerify').addEventListener('click', busy($('adVerify'), async () => {
|
||||
$('adErr').hidden = true;
|
||||
await api('/api/admin/auth/verify', { email: $('adEmail').value, code: $('adCode').value });
|
||||
await render();
|
||||
}));
|
||||
$('adCode').addEventListener('keydown', e => { if (e.key === 'Enter') $('adVerify').click(); });
|
||||
$('adEmail').addEventListener('keydown', e => { if (e.key === 'Enter') ($('adVerify').hidden ? $('adSend') : $('adVerify')).click(); });
|
||||
$('adLogout').addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
try { await api('/api/admin/auth/logout', {}); } catch (err) {}
|
||||
location.reload();
|
||||
});
|
||||
|
||||
// ── panes ──
|
||||
const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', traffic: 'Traffic', blog: 'Blog', releases: 'Releases and roadmap', pnl: 'Profit and loss', settings: 'Settings' };
|
||||
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, traffic: loadTraffic, blog: loadBlog, releases: loadReleases, pnl: loadPnl, settings: loadSettings };
|
||||
function setPane(name) {
|
||||
if (!TITLES[name]) name = 'overview';
|
||||
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; });
|
||||
document.querySelectorAll('.bo-menu [data-pane]').forEach(b => b.classList.toggle('on', b.dataset.pane === name));
|
||||
$('boTitle').textContent = TITLES[name];
|
||||
if (location.hash.slice(1) !== name) history.replaceState(null, '', '#' + name);
|
||||
$('adminArea').classList.remove('side-open');
|
||||
loaders[name]().catch(e => IAP.status(e.message, 'bad'));
|
||||
}
|
||||
document.querySelectorAll('.bo-menu [data-pane]').forEach(b => b.addEventListener('click', () => setPane(b.dataset.pane)));
|
||||
document.addEventListener('click', e => { const g = e.target.closest('[data-goto]'); if (g) setPane(g.dataset.goto); });
|
||||
window.addEventListener('hashchange', () => setPane(location.hash.slice(1)));
|
||||
$('boBurger').addEventListener('click', () => $('adminArea').classList.toggle('side-open'));
|
||||
|
||||
async function render() {
|
||||
let me = { admin: false };
|
||||
try { me = await api('/api/admin/me'); } catch (e) {}
|
||||
$('authArea').hidden = !!me.admin;
|
||||
$('adminArea').hidden = !me.admin;
|
||||
if (!me.admin) return;
|
||||
$('adWho').textContent = me.email || 'admin';
|
||||
try {
|
||||
const c = await IAP.getConfig();
|
||||
$('chainLine').textContent = c.chainName + (c.rehearsal ? ' · rehearsal' : '');
|
||||
} catch (e) {}
|
||||
setPane(location.hash.slice(1) || 'overview');
|
||||
}
|
||||
|
||||
// ── overview ──
|
||||
async function loadOverview() {
|
||||
const o = await api('/api/admin/overview');
|
||||
rates = o.rates || rates;
|
||||
$('ovAccounts').textContent = (o.accounts || 0).toLocaleString();
|
||||
$('ovMembers').textContent = o.memberCount == null ? '?' : Number(o.memberCount).toLocaleString();
|
||||
$('ovActive').textContent = (o.byStatus && o.byStatus.active) || 0;
|
||||
$('ovCampSub').textContent = o.campaigns + ' total · ' + o.house + ' house';
|
||||
$('ovReports').textContent = o.openReports || 0;
|
||||
$('ovBurnSub').textContent = (o.pendingBurns || 0) + ' pending burns';
|
||||
$('repBadge').hidden = !o.openReports; $('repBadge').textContent = o.openReports || '';
|
||||
const f = o.followups || {};
|
||||
$('ovDrips').textContent = f.active || 0;
|
||||
$('ovDripSub').textContent = (f.done || 0) + ' finished · ' + (f.unsubscribed || 0) + ' unsubscribed';
|
||||
const bt = Object.entries(o.byType || {}).sort((a, b) => b[1] - a[1]);
|
||||
$('ovByType').innerHTML = bt.length ? bt.map(([t, n]) => '<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--line)"><span>' + esc(t) + '</span><b>' + n + '</b></div>').join('') : 'No campaigns yet.';
|
||||
const ch = o.chain || {};
|
||||
$('ovChain').innerHTML = '<div>' + esc(ch.chainName) + ' (chain ' + esc(ch.chainId) + ')</div>'
|
||||
+ '<div class="mono" style="word-break:break-all;margin:6px 0">' + esc(ch.contract) + '</div>'
|
||||
+ (ch.explorer ? '<a href="' + esc(ch.explorer) + '/address/' + esc(ch.contract) + '" target="_blank" rel="noopener">Open in explorer →</a>' : '');
|
||||
}
|
||||
|
||||
// ── house ads ──
|
||||
const HROWS = { banner: ['hBannerRow'], text: ['hTextRow'], login: [], solo: ['hSoloRow'], video: ['hVideoRow'], featured: ['hFeatRow'], visits: ['hVisitsRow'] };
|
||||
function showHouseRows() {
|
||||
const t = $('hType').value;
|
||||
['hBannerRow', 'hTextRow', 'hSoloRow', 'hVideoRow', 'hFeatRow', 'hVisitsRow'].forEach(id => { $(id).hidden = !(HROWS[t] || []).includes(id); });
|
||||
$('hBudget').hidden = t === 'featured' || t === 'visits';
|
||||
houseHints();
|
||||
}
|
||||
function houseHints() {
|
||||
const r = rates || {};
|
||||
const soloCost = r.soloCostPerRecipient || 5, soloMin = r.soloMinRecipients || 10;
|
||||
const cap = Number($('hBudget').value) || 100000;
|
||||
$('hSoloHint').textContent = 'Delivers to one inbox per ' + soloCost + ' credits of cap (minimum ' + soloMin + ' recipients). A cap of ' + cap.toLocaleString() + ' reaches up to ' + Math.floor(cap / soloCost).toLocaleString() + ' members.';
|
||||
const days = Number($('hFeatDays').value) || 0;
|
||||
$('hFeatHint').textContent = days ? days + '-day run in the featured strip (' + (r.featuredPerDay || 40) + ' credits/day, free here). Book up to ' + (r.featuredWindowDays || 7) + ' days ahead.' : '';
|
||||
const n = Number($('hVisitCount').value) || 0;
|
||||
$('hVisitHint').textContent = 'Packs start at ' + (r.visitMinPack || 20) + ' visits.' + (n ? ' ' + n + ' verified visits, delivered one per member.' : '');
|
||||
}
|
||||
$('hType').addEventListener('change', showHouseRows);
|
||||
['hBudget', 'hFeatDays', 'hVisitCount'].forEach(id => $(id).addEventListener('input', houseHints));
|
||||
$('hFeatDays').addEventListener('change', houseHints);
|
||||
$('hImageUploadBtn').addEventListener('click', () => $('hImageFile').click());
|
||||
$('hVideoUploadBtn').addEventListener('click', () => $('hVideoFile').click());
|
||||
async function upload(fileInput, info, target, kind) {
|
||||
const f = fileInput.files[0]; if (!f) return;
|
||||
info.textContent = 'Uploading ' + f.name + '…';
|
||||
try {
|
||||
const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||||
if (r.error) { info.textContent = r.error; }
|
||||
else { target.value = r.url; info.textContent = f.name + ' uploaded'; }
|
||||
} catch (e) { info.textContent = 'Upload failed. Try again.'; }
|
||||
fileInput.value = '';
|
||||
}
|
||||
$('hImageFile').addEventListener('change', () => upload($('hImageFile'), $('hImageInfo'), $('hImage')));
|
||||
$('hVideoFile').addEventListener('change', () => upload($('hVideoFile'), $('hVideoInfo'), $('hVideoUrl')));
|
||||
let hVidDims = null;
|
||||
function probeVideoDims(url) {
|
||||
return new Promise(resolve => {
|
||||
const v = document.createElement('video'); v.preload = 'metadata'; v.muted = true;
|
||||
const done = d => { v.src = ''; resolve(d); };
|
||||
v.onloadedmetadata = () => done(v.videoWidth && v.videoHeight ? { w: v.videoWidth, h: v.videoHeight } : null);
|
||||
v.onerror = () => done(null);
|
||||
setTimeout(() => done(null), 12000);
|
||||
v.src = url;
|
||||
});
|
||||
}
|
||||
$('hCreate').addEventListener('click', busy($('hCreate'), async () => {
|
||||
$('hErr').hidden = true;
|
||||
const t = $('hType').value;
|
||||
if (t === 'video' && $('hVideoUrl').value) hVidDims = await probeVideoDims($('hVideoUrl').value);
|
||||
const days = Number($('hFeatDays').value), count = Number($('hVisitCount').value);
|
||||
const body = { type: t, name: $('hName').value, targetUrl: $('hTarget').value,
|
||||
imageUrl: $('hImage').value, size: $('hSize').value,
|
||||
title: t === 'video' ? $('hVideoTitle').value : t === 'featured' ? $('hFeatTitle').value : t === 'visits' ? $('hVisitTitle').value : t === 'solo' ? $('hSoloTitle').value : $('hTitle').value,
|
||||
body: t === 'solo' ? $('hSoloBody').value : $('hBody').value,
|
||||
ctaLabel: t === 'video' ? $('hVideoCta').value : $('hSoloCta').value,
|
||||
videoUrl: $('hVideoUrl').value, watchSecs: Number($('hWatchSecs').value),
|
||||
videoW: hVidDims ? hVidDims.w : null, videoH: hVidDims ? hVidDims.h : null,
|
||||
days, startDay: Number($('hFeatStart').value) || 0, count,
|
||||
budget: t === 'featured' ? days * (rates.featuredPerDay || 40)
|
||||
: t === 'visits' ? count * (rates.visitCostPerVisit || 3)
|
||||
: (Number($('hBudget').value) || 0) };
|
||||
try {
|
||||
await api('/api/admin/campaigns', body);
|
||||
} catch (e) { $('hErr').textContent = e.message; $('hErr').hidden = false; throw e; }
|
||||
IAP.status('House ad is live. It serves right away at no cost.', 'ok');
|
||||
['hName', 'hBudget', 'hTarget', 'hImage', 'hTitle', 'hBody', 'hSoloTitle', 'hSoloBody', 'hSoloCta',
|
||||
'hVideoUrl', 'hVideoTitle', 'hVideoCta', 'hFeatTitle', 'hVisitTitle', 'hVisitCount'].forEach(id => { $(id).value = ''; });
|
||||
$('hImageInfo').textContent = ''; $('hVideoInfo').textContent = ''; hVidDims = null;
|
||||
await loadHouse();
|
||||
}));
|
||||
function campRow(c, showOwner) {
|
||||
const left = Math.max(0, (c.budget || 0) - (c.spent || 0));
|
||||
const creative = c.type === 'banner' && c.imageUrl ? '<img src="' + esc(c.imageUrl) + '" alt="" style="max-height:34px;max-width:120px;border-radius:4px">' : esc(c.title || c.name);
|
||||
const act = c.status === 'active' ? '<button class="btn small sec" data-act="pause" data-id="' + c.id + '">Pause</button>'
|
||||
: c.status === 'paused' ? '<button class="btn small" data-act="resume" data-id="' + c.id + '">Resume</button>' : '';
|
||||
return '<tr><td>#' + c.id + (c.house ? '<span class="house-tag">HOUSE</span>' : '') + '</td>'
|
||||
+ (showOwner ? '<td><span class="trunc" title="' + esc(c.owner) + '">' + esc(c.house ? 'house' : c.owner) + '</span></td>' : '')
|
||||
+ '<td>' + esc(c.type) + '</td>'
|
||||
+ '<td>' + esc(c.name) + '<div class="small muted">' + creative + '</div><a class="small trunc" href="' + esc(c.targetUrl) + '" target="_blank" rel="noopener">' + esc(c.targetUrl) + '</a></td>'
|
||||
+ '<td><span class="st ' + esc(c.status) + '">' + esc(c.status) + '</span></td>'
|
||||
+ '<td class="mono small">' + (c.spent || 0).toLocaleString() + ' / ' + (c.budget || 0).toLocaleString() + '<div class="muted">' + left.toLocaleString() + ' left</div></td>'
|
||||
+ '<td class="mono small">' + (c.imps || 0).toLocaleString() + (c.impsNas ? ' +' + c.impsNas + ' nas' : '') + '<div class="muted">' + (c.clicks || 0) + ' clicks</div></td>'
|
||||
+ '<td class="small muted">' + when(c.created) + '</td>'
|
||||
+ '<td class="act">' + act + '</td></tr>';
|
||||
}
|
||||
function campHead(showOwner) {
|
||||
return '<tr><th>ID</th>' + (showOwner ? '<th>Owner</th>' : '') + '<th>Type</th><th>Campaign</th><th>Status</th><th>Spent / cap</th><th>Delivery</th><th>Created</th><th></th></tr>';
|
||||
}
|
||||
async function loadHouse() {
|
||||
const r = await api('/api/admin/campaigns');
|
||||
rates = r.rates || rates; sizes = r.bannerSizes || sizes; houseOwner = r.houseOwner || houseOwner;
|
||||
if (!$('hSize').options.length) $('hSize').innerHTML = sizes.map(s => '<option value="' + esc(s.id) + '">' + esc(s.label || s.id) + ' (' + s.w + '×' + s.h + ')</option>').join('');
|
||||
if (!$('hWatchSecs').options.length) $('hWatchSecs').innerHTML = (rates.videoTiers || []).map(t => '<option value="' + t.secs + '">Watch ' + t.secs + 's (viewer earns ' + t.reward + ')</option>').join('');
|
||||
if (!$('hFeatDays').options.length) $('hFeatDays').innerHTML = (rates.featuredDurations || [1, 2, 7]).map(d => '<option value="' + d + '">' + d + ' day' + (d > 1 ? 's' : '') + '</option>').join('');
|
||||
showHouseRows();
|
||||
loadWallAds();
|
||||
const house = (r.campaigns || []).filter(c => c.house);
|
||||
$('houseSub').textContent = house.filter(c => c.status === 'active').length + ' active · ' + house.length + ' total';
|
||||
$('houseTable').innerHTML = house.length ? campHead(false) + house.map(c => campRow(c, false)).join('') : '<tr><td class="muted">No house ads yet. Place one above.</td></tr>';
|
||||
}
|
||||
// wall fallback ads editor
|
||||
let wallAds = [];
|
||||
function drawWallAds() {
|
||||
const w = $('wallAdsList');
|
||||
w.innerHTML = wallAds.map((a, i) => '<div class="drip-step" data-i="' + i + '"><div class="ds-head"><span class="ds-n">WALL AD ' + (i + 1) + '</span>'
|
||||
+ '<span class="ds-tools"><button type="button" class="btn small sec" data-wact="up" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button type="button" class="btn small sec" data-wact="down" ' + (i === wallAds.length - 1 ? 'disabled' : '') + '>↓</button><button type="button" class="btn small sec" data-wact="remove">Remove</button></span></div>'
|
||||
+ '<div class="grid c3"><p><input class="wa-name" maxlength="60" placeholder="Label shown under the ad" value="' + esc(a.name || '') + '"></p>'
|
||||
+ '<p><input class="wa-target" placeholder="Link (https://…)" value="' + esc(a.targetUrl || '') + '"></p>'
|
||||
+ '<p><input class="wa-banner" placeholder="Banner image URL or upload" value="' + esc(a.bannerUrl || '') + '"> <button type="button" class="btn small sec wa-upload">Upload</button><input type="file" class="wa-file" accept="image/png,image/jpeg,image/webp,image/gif" hidden></p></div>'
|
||||
+ (a.bannerUrl ? '<img src="' + esc(a.bannerUrl) + '" alt="" style="max-height:60px;border-radius:6px">' : '')
|
||||
+ '</div>').join('') || '<p class="muted small">No wall ads set. Walls fall back to a plain LinkSpin card.</p>';
|
||||
}
|
||||
function readWallAds() {
|
||||
return [...document.querySelectorAll('#wallAdsList .drip-step')].map(c => ({ name: c.querySelector('.wa-name').value.trim(), targetUrl: c.querySelector('.wa-target').value.trim(), bannerUrl: c.querySelector('.wa-banner').value.trim() }));
|
||||
}
|
||||
async function loadWallAds() {
|
||||
try { const r = await api('/api/admin/wall-ads'); wallAds = r.ads || []; $('wallAdsSub').textContent = r.usingDefaults ? 'none set: walls show the default LinkSpin card' : wallAds.length + ' in rotation'; drawWallAds(); } catch (e) {}
|
||||
}
|
||||
$('wallAdsList').addEventListener('click', async e => {
|
||||
const up = e.target.closest('.wa-upload');
|
||||
if (up) { up.parentElement.querySelector('.wa-file').click(); return; }
|
||||
const b = e.target.closest('[data-wact]'); if (!b) return;
|
||||
const i = Number(b.closest('.drip-step').dataset.i); wallAds = readWallAds();
|
||||
if (b.dataset.wact === 'remove') wallAds.splice(i, 1);
|
||||
if (b.dataset.wact === 'up' && i > 0) [wallAds[i - 1], wallAds[i]] = [wallAds[i], wallAds[i - 1]];
|
||||
if (b.dataset.wact === 'down' && i < wallAds.length - 1) [wallAds[i + 1], wallAds[i]] = [wallAds[i], wallAds[i + 1]];
|
||||
drawWallAds();
|
||||
});
|
||||
$('wallAdsList').addEventListener('change', async e => {
|
||||
const f = e.target.closest('.wa-file'); if (!f || !f.files[0]) return;
|
||||
const file = f.files[0]; const card = f.closest('.drip-step');
|
||||
try {
|
||||
const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': file.type }, body: file })).json();
|
||||
if (r.error) IAP.status(r.error, 'bad'); else { card.querySelector('.wa-banner').value = r.url; IAP.status('Uploaded.', 'ok'); }
|
||||
} catch (err) { IAP.status('Upload failed.', 'bad'); }
|
||||
f.value = '';
|
||||
});
|
||||
$('wallAdsAdd').addEventListener('click', () => { wallAds = readWallAds(); wallAds.push({ name: '', targetUrl: '', bannerUrl: '' }); drawWallAds(); });
|
||||
$('wallAdsSave').addEventListener('click', busy($('wallAdsSave'), async () => {
|
||||
$('wallAdsErr').hidden = true;
|
||||
try { const r = await api('/api/admin/wall-ads', { ads: readWallAds() }, 'PATCH'); wallAds = r.ads || []; drawWallAds(); IAP.status('Wall ads saved.', 'ok'); await loadWallAds(); }
|
||||
catch (e) { $('wallAdsErr').textContent = e.message; $('wallAdsErr').hidden = false; }
|
||||
}));
|
||||
document.addEventListener('click', async e => {
|
||||
const b = e.target.closest('[data-act][data-id]'); if (!b) return;
|
||||
b.disabled = true;
|
||||
try {
|
||||
await api('/api/admin/campaigns/' + b.dataset.id + '/' + b.dataset.act, {});
|
||||
IAP.status('Campaign #' + b.dataset.id + ' ' + (b.dataset.act === 'pause' ? 'paused' : 'resumed') + '.', 'ok');
|
||||
await Promise.all([loadHouse(), loadCampaigns()]);
|
||||
} catch (err) { IAP.status(err.message, 'bad'); b.disabled = false; }
|
||||
});
|
||||
|
||||
// ── all campaigns ──
|
||||
let allCamps = [];
|
||||
async function loadCampaigns() {
|
||||
const r = await api('/api/admin/campaigns');
|
||||
allCamps = r.campaigns || [];
|
||||
drawCamps();
|
||||
}
|
||||
function drawCamps() {
|
||||
const q = ($('campFilter').value || '').trim().toLowerCase();
|
||||
const list = allCamps.filter(c => !q || [c.owner, c.name, c.type, c.status, c.targetUrl, String(c.id)].join(' ').toLowerCase().includes(q));
|
||||
$('campSub').textContent = list.length + ' of ' + allCamps.length;
|
||||
$('campTable').innerHTML = list.length ? campHead(true) + list.map(c => campRow(c, true)).join('') : '<tr><td class="muted">Nothing matches.</td></tr>';
|
||||
}
|
||||
$('campFilter').addEventListener('input', drawCamps);
|
||||
|
||||
// ── members ──
|
||||
let allMembers = [];
|
||||
async function loadTank() {
|
||||
try {
|
||||
const r = await (await fetch('/api/admin/tank')).json(); if (r.error) return;
|
||||
$('tankAdmSub').textContent = r.waiting.length + ' waiting · cap ' + r.cap + ' open per adopter · ' + r.ttlDays + '-day window';
|
||||
$('tankWait').innerHTML = '<tr><th>Waiting</th><th>Email</th><th>Joined</th><th>Last sign-in</th></tr>' + (r.waiting.length ? r.waiting.map(w => '<tr><td>' + esc(w.name) + '</td><td>' + esc(w.email) + '</td><td class="when">' + when(w.joined) + '</td><td class="when">' + (w.lastSeen ? when(w.lastSeen) : '<span class="muted">never</span>') + '</td></tr>').join('') : '<tr><td colspan="4" class="muted">empty</td></tr>');
|
||||
$('tankAdopt').innerHTML = '<tr><th>Member</th><th>Adopted by</th><th>When</th><th>Window ends</th><th>Status</th></tr>' + (r.adoptions.length ? r.adoptions.map(a => '<tr><td>' + esc(a.adopteeName) + '</td><td>' + esc(a.adopterName) + '</td><td class="when">' + when(a.ts) + '</td><td class="when">' + (a.status === 'released' ? '' : when(a.expires)) + '</td><td>' + esc(a.status) + '</td></tr>').join('') : '<tr><td colspan="5" class="muted">none yet</td></tr>');
|
||||
} catch (e) {}
|
||||
}
|
||||
async function loadMembers() {
|
||||
loadTank();
|
||||
const r = await api('/api/admin/members');
|
||||
allMembers = r.members || [];
|
||||
drawMembers();
|
||||
}
|
||||
function drawMembers() {
|
||||
const q = ($('memFilter').value || '').trim().toLowerCase();
|
||||
const list = allMembers.filter(a => !q || [a.email, a.username, a.memberId, a.sponsorRef, a.address, a.code].join(' ').toLowerCase().includes(q));
|
||||
$('memSub').textContent = list.length + ' of ' + allMembers.length;
|
||||
$('memTable').innerHTML = '<tr><th>Email</th><th>Username</th><th>Member #</th><th>Wallet</th><th>Sponsor</th><th>Positions</th><th>Via</th><th>Code</th><th>Joined</th><th></th></tr>'
|
||||
+ list.map(a => '<tr><td>' + esc(a.email) + '</td><td>' + (a.username ? '@' + esc(a.username) : '<span class="muted">none</span>') + '</td>'
|
||||
+ '<td>' + (a.memberId ? '#' + a.memberId : '<span class="muted">free</span>') + '</td>'
|
||||
+ '<td class="mono small">' + (a.address ? esc(a.address.slice(0, 8) + '…' + a.address.slice(-6)) : '<span class="muted">none</span>') + '</td>'
|
||||
+ '<td>' + (a.sponsorName ? esc(a.sponsorName) + (a.sponsorVia === 'code' ? ' <span class="muted small" title="joined through this share code">via code ' + esc(a.sponsorRef) + '</span>' : a.sponsorVia === 'member #' ? ' <span class="muted small">via #' + esc(a.sponsorRef) + '</span>' : '') : a.sponsorRef ? '<span class="badge amber" title="this token points at nobody; the member will move to the holding tank">dead link: ' + esc(a.sponsorRef) + '</span>' : '<span class="muted">none</span>') + '</td><td class="small" title="linked Qualified Start positions' + (a.positionIds && a.positionIds.length ? ': #' + a.positionIds.join(', #') : '') + '">' + (a.positions ? a.positions : '<span class="muted">0</span>') + '</td><td class="small muted">' + esc(a.joinedVia || '') + '</td><td class="mono small">' + esc(a.code || '') + '</td>'
|
||||
+ '<td class="small muted when">' + when(a.created) + '</td>'
|
||||
+ '<td class="act"><button class="btn small sec" data-mcopen="' + esc(a.email) + '">Open</button> <button class="btn small sec" data-spon="' + esc(a.email) + '" data-cur="' + esc(a.sponsorRef || '') + '">Sponsor</button></td></tr>').join('');
|
||||
}
|
||||
$('memFilter').addEventListener('input', drawMembers);
|
||||
document.addEventListener('click', async e => {
|
||||
const b = e.target.closest('[data-spon]'); if (!b) return;
|
||||
const v = await IAP.ask({ title: 'Sponsor for ' + b.dataset.spon, text: 'Username, share code, or member #. Leave blank to clear.', value: b.dataset.cur, ok: 'Save' });
|
||||
if (v === null || v === undefined) return;
|
||||
try {
|
||||
await api('/api/admin/members', { email: b.dataset.spon, sponsorRef: v.trim() }, 'PATCH');
|
||||
IAP.status('Sponsor updated.', 'ok');
|
||||
await loadMembers();
|
||||
} catch (err) { IAP.status(err.message, 'bad'); }
|
||||
});
|
||||
|
||||
// ── member card: search, drill down, act (Marty, 2026-09-13) ──
|
||||
let mcCur = null;
|
||||
const polOf = w => { try { return (Number(BigInt(w || '0') / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }); } catch (e) { return '0'; } };
|
||||
const ago = ts => { if (!ts) return 'never'; const d = Date.now() - Number(ts); const h = Math.floor(d / 3600000); return h < 1 ? Math.max(1, Math.floor(d / 60000)) + ' min ago' : h < 48 ? h + ' h ago' : Math.floor(h / 24) + ' days ago'; };
|
||||
const memLink = (email, label) => '<a href="#" data-mcopen="' + esc(email) + '">' + esc(label) + '</a>';
|
||||
async function openMember(q) {
|
||||
const msg = $('memSearchMsg'); msg.hidden = true;
|
||||
let d;
|
||||
try { d = await api('/api/admin/member?q=' + encodeURIComponent(q)); } catch (e) { msg.textContent = e.message; msg.hidden = false; msg.className = 'small bad'; return; }
|
||||
renderMember(d);
|
||||
}
|
||||
function kv(rows) { return '<table class="adm-table kv">' + rows.map(r => '<tr><th style="width:170px">' + r[0] + '</th><td>' + r[1] + '</td></tr>').join('') + '</table>'; }
|
||||
function renderMember(d) {
|
||||
mcCur = d; const a = d.account; $('memHits').hidden = true;
|
||||
$('memCard').hidden = false; document.querySelectorAll('#pane-members > .card').forEach(c => { if (c.id !== 'memCard' && c.id !== 'memSearchCard') c.hidden = true; });
|
||||
$('mcName').textContent = (a.username ? '@' + a.username : a.email) + (a.memberId ? ' · member #' + a.memberId : ' · free member');
|
||||
$('mcSub').textContent = 'joined ' + when(a.created) + ' · last seen ' + ago(a.lastSeen);
|
||||
$('mcWall').hidden = !a.username; if (a.username) $('mcWall').href = '/wall/' + a.username;
|
||||
const ch = d.chain, cr = d.credits, t = d.totals;
|
||||
const level = ch && !ch.readError ? (ch.buyerCount >= 5 ? 'level 3 (5+ buyers)' : ch.buyerCount >= 2 ? 'level 2 (2 buyers)' : 'level 1') : '';
|
||||
let h = '<div class="grid c2">';
|
||||
h += '<div><h4 style="margin:0 0 6px">Identity</h4>' + kv([
|
||||
['Email', esc(a.email)], ['Username', a.username ? '@' + esc(a.username) : '<span class="muted">not set</span>'], ['Share code', esc(a.code || '')],
|
||||
['Main wallet', a.address ? '<span class="mono small">' + esc(a.address) + '</span>' : '<span class="muted">none linked</span>'],
|
||||
['Extra positions', d.positions.length ? d.positions.map(p => '<span class="mono small">' + esc(p.address.slice(0, 8) + '…' + p.address.slice(-6)) + '</span>' + (p.memberId ? ' = #' + p.memberId : ' (unregistered)')).join('<br>') : '<span class="muted">none</span>'],
|
||||
['Sponsor (site)', d.upline.length ? memLink(d.upline[0].email, d.upline[0].name) + ' <span class="muted small">token ' + esc(a.sponsorRef || '') + '</span>' : (a.sponsorRef ? '<span class="muted">unresolved: ' + esc(a.sponsorRef) + '</span>' : '<span class="muted">none (company)</span>')],
|
||||
['Upline chain', d.upline.length > 1 ? d.upline.map(u => memLink(u.email, u.name)).join(' → ') : '<span class="muted">-</span>'],
|
||||
['Joined via', esc(a.joinedVia || 'join page') + (a.joinedRef ? ' from ' + esc(a.joinedRef) : '')],
|
||||
['Line banner', a.lineTargetUrl ? '<a href="' + esc(a.lineTargetUrl) + '" target="_blank" rel="noopener">' + esc(a.lineTargetUrl.slice(0, 50)) + '</a>' : '<span class="muted">not set</span>'],
|
||||
['Chat', a.chatAvailable ? 'available' : 'switched off']]) + '</div>';
|
||||
h += '<div><h4 style="margin:0 0 6px">On-chain and money</h4>' + kv([
|
||||
['Registered', ch ? (ch.readError ? 'read error' : 'yes, #' + ch.memberId + ' under ' + (ch.sponsorId ? '#' + ch.sponsorId + (d.names[ch.sponsorId] ? ' @' + esc(d.names[ch.sponsorId]) : '') : 'nobody')) : '<span class="muted">no (payouts off)</span>'],
|
||||
['Qualifying buyers', ch && !ch.readError ? ch.buyerCount + ' · ' + level : '-'],
|
||||
['Packages bought', t.purchases + (t.purchases ? ' · $' + (t.spentCents / 100).toFixed(0) + ' · ' + polOf(t.spentWei) + ' POL' : '')],
|
||||
['Payouts received', t.payoutsIn + (t.payoutsIn ? ' · ' + polOf(t.receivedWei) + ' POL' : '')],
|
||||
['Credits', cr ? cr.available.toLocaleString() + ' available · ' + cr.inCampaigns.toLocaleString() + ' in campaigns · ' + cr.total.toLocaleString() + ' total' : '<span class="muted">-</span>'],
|
||||
['Earned pool', d.earnedSplit ? d.earnedSplit.total.toLocaleString() + ' (' + (d.earnedSplit.grade || 0).toLocaleString() + ' purchased-grade)' : '-'],
|
||||
['Old-site account', d.legacy ? 'had a ' + (d.legacy.brand === 'both' ? 'Faucet Wave and Tier One Ads' : d.legacy.brand === 'tier1ads' ? 'Tier One Ads' : 'Faucet Wave') + ' account (' + d.legacy.seg + ') · welcome-back credits ' + (d.legacy.grant ? d.legacy.grant.credits + ' issued ' + when(d.legacy.grant.at) : 'not issued (joined outside the legacy bridge)') : '<span class="muted">none on record</span>'],
|
||||
['Promo codes', d.promos.length ? d.promos.map(p => esc(p.code) + ' (' + p.credits + ', ' + when(p.ts) + ')').join('<br>') : '<span class="muted">none</span>'],
|
||||
['Drip', d.drip ? (d.drip.stopped ? 'stopped' : 'step ' + d.drip.step + ', next ' + when(d.drip.next_at)) + (d.drip.angle ? ' · ' + esc(d.drip.angle) : '') : '<span class="muted">-</span>'],
|
||||
['Holding tank', d.tank ? (d.tank.waiting ? '<b>waiting for a sponsor</b>' : 'not in tank') + (d.tank.adoptedBy.length ? ' · adopted by ' + d.tank.adoptedBy.map(x => memLink(x.email, x.name)).join(', ') : '') + (d.tank.adopted.length ? ' · adopted ' + d.tank.adopted.map(x => memLink(x.email, x.name)).join(', ') : '') : '-'],
|
||||
['Earning', d.earning ? 'today ' + d.earning.today + '/5' + (d.earning.claimed ? ' claimed' : '') + ' · streak day ' + d.earning.streakDay + (d.activeDays14 !== undefined ? ' · active ' + d.activeDays14 + ' of last 14 days, ' + d.claims14 + ' claims' : '') : '-'],
|
||||
['Visits / videos / chat', (d.visits || 0) + ' verified visits · ' + (d.videos || 0) + ' video watches · ' + (d.messageCount || 0) + ' messages']]) + '</div></div>';
|
||||
// line
|
||||
h += '<h4 style="margin:18px 0 6px">Line (' + d.lineCounts.join(' / ') + ')</h4>';
|
||||
if (!d.line.length) h += '<p class="muted small">Nobody in their line yet.</p>';
|
||||
for (const L of d.line) {
|
||||
h += '<p class="small muted" style="margin:8px 0 4px">Level ' + L.level + ' · ' + L.members.length + '</p><div class="tablewrap"><table class="adm-table"><tr><th>Member</th><th>Member #</th><th>Wallet</th><th>Bought</th><th>Qualified</th><th>Joined</th><th>Last seen</th></tr>'
|
||||
+ L.members.map(m => '<tr><td>' + memLink(m.email, m.name) + (L.level === 1 ? '<br><span class="muted small">' + esc(m.email) + '</span>' : '') + '</td><td>' + (m.memberId ? '#' + m.memberId : '<span class="muted">free</span>') + '</td><td>' + (m.wallet ? 'yes' : '<span class="muted">no</span>') + '</td><td>' + (m.bought ? 'yes' : '<span class="muted">no</span>') + '</td><td>' + (m.qualified ? '<span class="chip-t on">yes</span>' : '') + '</td><td class="small muted">' + when(m.joined) + '</td><td class="small muted">' + ago(m.lastSeen) + '</td></tr>').join('') + '</table></div>';
|
||||
}
|
||||
// purchases + payouts + campaigns
|
||||
h += '<div class="grid c2" style="margin-top:18px"><div><h4 style="margin:0 0 6px">Purchases</h4><div class="tablewrap"><table class="adm-table"><tr><th>When</th><th>Position</th><th>Package</th><th>Paid</th><th>Tx</th></tr>'
|
||||
+ (d.purchases.length ? d.purchases.map(p => '<tr><td class="small">' + when(p.ts) + '</td><td>#' + p.buyerId + '</td><td>$' + (p.priceCents / 100).toFixed(0) + ' · ' + Number(p.credits || 0).toLocaleString() + ' cr</td><td>' + polOf(p.paidWei) + ' POL</td><td><a href="/tx/' + esc(p.tx) + '" target="_blank" rel="noopener" class="mono small">' + esc(p.tx.slice(0, 10)) + '…</a></td></tr>').join('') : '<tr><td colspan="5" class="muted">No purchases.</td></tr>') + '</table></div></div>';
|
||||
h += '<div><h4 style="margin:0 0 6px">Payouts received</h4><div class="tablewrap"><table class="adm-table"><tr><th>When</th><th>From</th><th>Tier</th><th>Amount</th></tr>'
|
||||
+ (d.received.length ? d.received.map(r => '<tr><td class="small">' + when(r.ts) + '</td><td>#' + r.buyerId + (d.names[r.buyerId] ? ' @' + esc(d.names[r.buyerId]) : '') + '</td><td>' + r.tier + '</td><td>' + polOf(r.amountWei) + ' POL</td></tr>').join('') : '<tr><td colspan="4" class="muted">Nothing received yet.</td></tr>') + '</table></div></div></div>';
|
||||
h += '<h4 style="margin:18px 0 6px">Campaigns (' + d.campaigns.length + ')</h4><div class="tablewrap"><table class="adm-table"><tr><th>#</th><th>Type</th><th>Status</th><th>Budget</th><th>Spent</th><th>Views</th><th>Clicks</th><th>Created</th></tr>'
|
||||
+ (d.campaigns.length ? d.campaigns.map(c => '<tr><td>' + c.id + '</td><td>' + esc(c.type) + '</td><td>' + esc(c.status) + '</td><td>' + Number(c.budget || 0).toLocaleString() + '</td><td>' + Number(c.spent || 0).toLocaleString() + '</td><td>' + Number(c.views || 0).toLocaleString() + '</td><td>' + Number(c.clicks || 0).toLocaleString() + '</td><td class="small muted">' + when(c.created) + '</td></tr>').join('') : '<tr><td colspan="8" class="muted">No campaigns.</td></tr>') + '</table></div>';
|
||||
$('mcBody').innerHTML = h;
|
||||
if (location.hash !== '#members') history.replaceState(null, '', '#members');
|
||||
}
|
||||
function closeMember() { $('memCard').hidden = true; document.querySelectorAll('#pane-members > .card').forEach(c => { c.hidden = false; }); }
|
||||
// live matches while typing: any part of the username, email, member #, share code or wallet
|
||||
let memHitList = [];
|
||||
function memMatches(q) {
|
||||
q = q.toLowerCase();
|
||||
return allMembers.filter(a => [a.username, a.email, a.memberId ? '#' + a.memberId : '', a.memberId, a.code, a.address, a.sponsorName].filter(Boolean).join(' ').toLowerCase().includes(q)).slice(0, 12);
|
||||
}
|
||||
async function memTypeahead() {
|
||||
const q = $('memSearch').value.trim();
|
||||
if (!allMembers.length) { try { const r = await api('/api/admin/members'); allMembers = r.members || []; } catch (e) {} }
|
||||
if (q.length < 2) { $('memHits').hidden = true; memHitList = []; return; }
|
||||
memHitList = memMatches(q);
|
||||
$('memHits').innerHTML = memHitList.length ? memHitList.map(a => '<button type="button" data-mcopen="' + esc(a.email) + '" style="display:flex;gap:12px;width:100%;text-align:left;background:transparent;border:0;border-bottom:1px solid var(--line);padding:8px 12px;color:inherit;cursor:pointer;font:inherit"><b style="min-width:140px">' + (a.username ? '@' + esc(a.username) : '<span class="muted">no username</span>') + '</b><span>' + esc(a.email) + '</span><span class="muted">' + (a.memberId ? '#' + a.memberId : 'free') + (a.sponsorName ? ' · under ' + esc(a.sponsorName) : '') + '</span></button>').join('')
|
||||
: '<p class="muted small" style="margin:0;padding:8px 12px">No member matches that.</p>';
|
||||
$('memHits').hidden = false;
|
||||
}
|
||||
$('memSearch').addEventListener('input', memTypeahead);
|
||||
$('memSearch').addEventListener('focus', memTypeahead);
|
||||
$('memOpen').addEventListener('click', () => { const q = $('memSearch').value.trim(); if (!q) return; if (memHitList.length) openMember(memHitList[0].email); else openMember(q); });
|
||||
$('memSearch').addEventListener('keydown', e => { if (e.key === 'Enter') $('memOpen').click(); if (e.key === 'Escape') $('memHits').hidden = true; });
|
||||
document.addEventListener('click', e => { if (!e.target.closest('#memSearchCard')) $('memHits').hidden = true; });
|
||||
$('mcBack').addEventListener('click', closeMember);
|
||||
document.addEventListener('click', e => { const l = e.target.closest('[data-mcopen]'); if (l) { e.preventDefault(); openMember(l.dataset.mcopen); } });
|
||||
document.querySelectorAll('[data-mcact]').forEach(b => b.addEventListener('click', busy(b, async () => {
|
||||
if (!mcCur) return; const a = mcCur.account, act = b.dataset.mcact; let body = null;
|
||||
if (act === 'username') { const v = await IAP.ask({ title: 'Username for ' + a.email, text: '3-20 letters, numbers or underscore. Changing it breaks any invite links they already handed out.', value: a.username || '', ok: 'Save' }); if (v === null || v === undefined) return; body = { username: v }; }
|
||||
if (act === 'sponsor') { const v = await IAP.ask({ title: 'Sponsor for ' + (a.username ? '@' + a.username : a.email), text: 'Username, share code or member #. Blank = no sponsor (company). Re-points free referrals and future purchases; on-chain sponsorship never changes.', value: a.sponsorRef || '', ok: 'Save' }); if (v === null || v === undefined) return; body = { sponsorRef: v }; }
|
||||
if (act === 'wallet') { const v = await IAP.ask({ title: 'Main wallet for ' + a.email, text: 'Paste the 0x address that should be their main wallet (the one that paid, if a purchase came from an unlinked account). The member number is re-read from the chain. Blank = unlink.', value: a.address || '', ok: 'Swap' }); if (v === null || v === undefined) return; if (!await IAP.confirmBox('Swap the main wallet for ' + a.email + ' to ' + (v.trim() || 'nothing') + '?', { title: 'Sure?', ok: 'Swap it', cancel: 'Cancel' })) return; body = { address: v }; }
|
||||
if (act === 'credits') { const v = await IAP.ask({ title: 'Grant credits to ' + (a.username ? '@' + a.username : a.email), text: 'Whole number of earned-pool credits (1 credit = 1 cent of delivery). They can spend them on campaigns right away.', type: 'number', value: '', placeholder: '250', ok: 'Grant' }); if (!v) return; const note = await IAP.ask({ title: 'Reason (kept in the server log)', value: '', placeholder: 'e.g. refund for broken banner', ok: 'Grant' }); body = { grantCredits: v, note: note || '' }; }
|
||||
if (act === 'delete') {
|
||||
if (a.memberId) { IAP.status('Registered members cannot be deleted; their position is on-chain.', 'bad'); return; }
|
||||
if (!await IAP.confirmBox('Delete the free account ' + a.email + '? Their sign-in, referrals link and credits go away. There is no undo.', { title: 'Delete account', ok: 'Delete', cancel: 'Keep it' })) return;
|
||||
await api('/api/admin/member?email=' + encodeURIComponent(a.email), undefined, 'DELETE'); IAP.status('Account deleted.', 'ok'); closeMember(); loadMembers().catch(() => {}); return;
|
||||
}
|
||||
if (!body) return;
|
||||
const d = await api('/api/admin/member', Object.assign({ email: a.email }, body), 'PATCH');
|
||||
renderMember(d); IAP.status('Saved.', 'ok'); loadMembers().catch(() => {});
|
||||
})));
|
||||
|
||||
// ── every admin table: click a header to sort (numbers sort as numbers), inputs with
|
||||
// class "tfilter" filter the table named in data-for ──
|
||||
document.addEventListener('click', e => {
|
||||
const th = e.target.closest('.adm-table th'); if (!th || th.closest('table').classList.contains('kv')) return;
|
||||
const table = th.closest('table'), hdr = th.parentElement, idx = [...hdr.children].indexOf(th);
|
||||
const rows = [...table.querySelectorAll('tr')].filter(r => r !== hdr && r.children.length > 1);
|
||||
const num = s => { const t = String(s).replace(/[$,%\s]/g, '').replace(/…$/, ''); return t !== '' && !isNaN(t) ? Number(t) : null; };
|
||||
const dir = th.dataset.dir === 'asc' ? 'desc' : 'asc';
|
||||
hdr.querySelectorAll('th').forEach(x => { delete x.dataset.dir; x.classList.remove('sort-asc', 'sort-desc'); });
|
||||
th.dataset.dir = dir; th.classList.add('sort-' + dir);
|
||||
rows.sort((r1, r2) => { const a = (r1.children[idx] || {}).textContent || '', b = (r2.children[idx] || {}).textContent || ''; const na = num(a), nb = num(b); const c = na !== null && nb !== null ? na - nb : a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); return dir === 'asc' ? c : -c; });
|
||||
rows.forEach(r => (hdr.parentElement).appendChild(r));
|
||||
});
|
||||
document.addEventListener('input', e => {
|
||||
const inp = e.target.closest('.tfilter'); if (!inp) return;
|
||||
const table = $(inp.dataset.for); if (!table) return;
|
||||
const q = inp.value.trim().toLowerCase(); let shown = 0;
|
||||
[...table.querySelectorAll('tr')].forEach((r, i) => { if (i === 0 || r.querySelector('th')) return; const hit = !q || r.textContent.toLowerCase().includes(q); r.hidden = !hit; if (hit) shown++; });
|
||||
const c = inp.parentElement.querySelector('.tfilter-count'); if (c) c.textContent = q ? shown + ' shown' : '';
|
||||
});
|
||||
|
||||
// ── release notes + roadmap (Marty, 2026-09-14) ──
|
||||
async function loadReleases() {
|
||||
loadUpdates();
|
||||
const d = await api('/api/admin/releases');
|
||||
$('rnSub').textContent = d.notes.length + ' notes'; $('rmSub').textContent = d.roadmap.length + ' items';
|
||||
$('rnTable').innerHTML = '<tr><th>Date</th><th>Title</th><th>Tags</th><th></th></tr>' + (d.notes.length ? d.notes.map(n => '<tr><td class="small">' + esc(n.date) + '</td><td><b>' + esc(n.title) + '</b></td><td class="small">' + esc(n.tags.join(', ')) + '</td><td class="act"><button type="button" class="btn small sec" data-rnedit="' + esc(n.id) + '">Edit</button> <button type="button" class="btn small sec" data-rndel="' + esc(n.id) + '">Delete</button></td></tr>').join('') : '<tr><td colspan="4" class="muted">No notes yet.</td></tr>');
|
||||
$('rmTable').innerHTML = '<tr><th>Status</th><th>Title</th><th>ETA</th><th>#</th><th></th></tr>' + (d.roadmap.length ? d.roadmap.map(r => '<tr><td class="small">' + esc(r.status) + '</td><td><b>' + esc(r.title) + '</b>' + (r.note ? '<br><span class="muted small">' + esc(r.note) + '</span>' : '') + '</td><td class="small">' + esc(r.eta || '') + '</td><td class="small">' + (r.order || '') + '</td><td class="act"><button type="button" class="btn small sec" data-rmedit="' + esc(r.id) + '">Edit</button> <button type="button" class="btn small sec" data-rmdel="' + esc(r.id) + '">Delete</button></td></tr>').join('') : '<tr><td colspan="5" class="muted">Nothing on the roadmap yet.</td></tr>');
|
||||
$('rnTable').querySelectorAll('[data-rnedit]').forEach(b => b.addEventListener('click', () => { const n = d.notes.find(x => x.id === b.dataset.rnedit); if (!n) return; $('rnId').value = n.id; $('rnTitle').value = n.title; $('rnDate').value = n.date; $('rnTags').value = n.tags.join(', '); $('rnBody').value = n.body; $('rnTitle').focus(); }));
|
||||
$('rmTable').querySelectorAll('[data-rmedit]').forEach(b => b.addEventListener('click', () => { const r = d.roadmap.find(x => x.id === b.dataset.rmedit); if (!r) return; $('rmId').value = r.id; $('rmTitle').value = r.title; $('rmStatus').value = r.status; $('rmEta').value = r.eta || ''; $('rmOrder').value = r.order || ''; $('rmNote').value = r.note || ''; $('rmTitle').focus(); }));
|
||||
$('rnTable').querySelectorAll('[data-rndel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this release note?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=notes&id=' + encodeURIComponent(b.dataset.rndel), undefined, 'DELETE'); loadReleases(); }));
|
||||
$('rmTable').querySelectorAll('[data-rmdel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this roadmap item?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=roadmap&id=' + encodeURIComponent(b.dataset.rmdel), undefined, 'DELETE'); loadReleases(); }));
|
||||
}
|
||||
// ── member update emails (Marty, 2026-09-14) ──
|
||||
async function loadUpdates() {
|
||||
if (!$('updCard')) return;
|
||||
try {
|
||||
const d = await api('/api/admin/updates');
|
||||
$('updSub').textContent = (d.mailer ? '' : 'mailer not configured · ') + (d.lastSentAt ? 'last send ' + new Date(d.lastSentAt).toLocaleString() : 'nothing sent yet') + (d.running ? ' · sending now' : '');
|
||||
$('updAudience').innerHTML = Object.entries(d.audiences).map(([k, v]) => '<option value="' + k + '">' + esc(v) + ' (' + (d.counts[k] || 0) + ')</option>').join('');
|
||||
$('updNotes').innerHTML = d.notes.length ? d.notes.map(n => '<label class="small" style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" value="' + esc(n.id) + '"' + (n.fresh ? ' checked' : '') + '><span>' + esc(n.title) + ' <span class="muted">' + esc(n.date) + '</span></span></label>').join('') : '<span class="muted small">No release notes yet.</span>';
|
||||
$('updLog').innerHTML = '<tr><th>When</th><th>Subject</th><th>Audience</th><th>Sent</th></tr>' + (d.sends.length ? d.sends.map(x => '<tr><td class="small">' + new Date(x.ts).toLocaleString() + '</td><td>' + esc(x.subject) + '</td><td class="small">' + esc(d.audiences[x.audience] || x.audience) + '</td><td class="small">' + x.sent + ' of ' + x.total + (x.skipped ? ' · ' + x.skipped + ' opted out' : '') + (x.failed ? ' · ' + x.failed + ' failed' : '') + (x.unknown ? ' · ' + x.unknown + ' unchecked (Sendy gave no answer)' : '') + (x.status === 'running' ? ' · running' : '') + '</td></tr>').join('') : '<tr><td colspan="4" class="muted small">None yet.</td></tr>');
|
||||
if (d.draft && !updDraftLoaded) { updDraftLoaded = true; $('updSubject').value = d.draft.subject || ''; $('updIntro').value = d.draft.intro || ''; $('updClosing').value = d.draft.closing || ''; if (d.draft.audience) $('updAudience').value = d.draft.audience; $('updNotes').querySelectorAll('input').forEach(i => { i.checked = (d.draft.noteIds || []).includes(i.value); }); $('updSub').textContent += ' · draft loaded (saved ' + new Date(d.draft.savedAt).toLocaleString() + ')'; }
|
||||
if (d.running) setTimeout(loadUpdates, 4000);
|
||||
} catch (e) { $('updSub').textContent = e.message; }
|
||||
}
|
||||
let updDraftLoaded = false;
|
||||
const updInput = () => ({ subject: $('updSubject').value, intro: $('updIntro').value, closing: $('updClosing').value, noteIds: [...$('updNotes').querySelectorAll('input:checked')].map(i => i.value), audience: $('updAudience').value });
|
||||
if ($('updCard')) {
|
||||
$('updSaveDraft').addEventListener('click', busy($('updSaveDraft'), async () => { await api('/api/admin/updates/draft', updInput()); IAP.status('Draft saved.', 'ok'); }));
|
||||
$('updPreview').addEventListener('click', busy($('updPreview'), async () => { const r = await api('/api/admin/updates/preview', updInput()); $('updPre').hidden = false; $('updPre').textContent = 'Subject: ' + r.subject + '\n\n' + r.text; }));
|
||||
$('updTest').addEventListener('click', busy($('updTest'), async () => { const r = await api('/api/admin/updates/send', Object.assign(updInput(), { test: true })); IAP.status('Test sent to ' + r.to + '.', 'ok'); }));
|
||||
$('updSend').addEventListener('click', busy($('updSend'), async () => {
|
||||
const inp = updInput(); if (!inp.noteIds.length) { IAP.status('Pick at least one note.', 'bad'); return; }
|
||||
const opt = $('updAudience').selectedOptions[0].textContent;
|
||||
if (!(await IAP.confirmBox('Send this update to ' + opt + '? One email per member, opt-outs skipped.', { title: 'Send member update', ok: 'Send now', cancel: 'Not yet' }))) return;
|
||||
const r = await api('/api/admin/updates/send', inp); IAP.status('Sending to ' + r.total + ' members in the background.', 'ok'); loadUpdates();
|
||||
}));
|
||||
}
|
||||
$('rnClear').addEventListener('click', () => { ['rnId', 'rnTitle', 'rnDate', 'rnTags', 'rnBody'].forEach(id => { $(id).value = ''; }); });
|
||||
$('rmClear').addEventListener('click', () => { ['rmId', 'rmTitle', 'rmEta', 'rmOrder', 'rmNote'].forEach(id => { $(id).value = ''; }); $('rmStatus').value = 'planned'; });
|
||||
$('rnSave').addEventListener('click', busy($('rnSave'), async () => { await api('/api/admin/releases', { kind: 'note', id: $('rnId').value || null, title: $('rnTitle').value, date: $('rnDate').value, tags: $('rnTags').value, body: $('rnBody').value }); IAP.status('Note saved.', 'ok'); $('rnClear').click(); loadReleases(); }));
|
||||
$('rmSave').addEventListener('click', busy($('rmSave'), async () => { await api('/api/admin/releases', { kind: 'roadmap', id: $('rmId').value || null, title: $('rmTitle').value, status: $('rmStatus').value, eta: $('rmEta').value, order: $('rmOrder').value, note: $('rmNote').value }); IAP.status('Roadmap item saved.', 'ok'); $('rmClear').click(); loadReleases(); }));
|
||||
|
||||
// ── reports + burns ──
|
||||
// ── profit and loss ──
|
||||
let pnlDays = 30;
|
||||
const pol = w => { try { return (Number(BigInt(w || '0') / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }); } catch (e) { return '0'; } };
|
||||
const usdOf = (w, px) => { try { return '$' + ((Number(BigInt(w || '0') / 10n ** 14n) / 10000) * px).toLocaleString(undefined, { maximumFractionDigits: 0 }); } catch (e) { return '$0'; } };
|
||||
// ── traffic: referring domains / sources, landing pages, angles, by day ──
|
||||
let trfDays = 30;
|
||||
document.querySelectorAll('#trfRange [data-days]').forEach(b => b.addEventListener('click', () => { trfDays = Number(b.dataset.days); document.querySelectorAll('#trfRange [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadTraffic().catch(e => IAP.status(e.message, 'bad')); }));
|
||||
async function loadPromos() {
|
||||
const d = await (await fetch('/api/admin/promos')).json();
|
||||
if (d.error) throw new Error(d.error);
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const when = t => t ? new Date(t).toLocaleDateString() : '';
|
||||
$('pcTable').innerHTML = '<tr><th>Code</th><th>Credits</th><th>Partner</th><th>Uses</th><th>Max</th><th>Expires</th><th>Status</th><th></th></tr>'
|
||||
+ (d.codes.length ? d.codes.map(c => '<tr><td><b>' + esc(c.code) + '</b></td><td>' + c.credits.toLocaleString() + '</td><td>' + esc(c.partner) + '</td><td>' + c.uses + '</td><td>' + (c.maxUses || '∞') + '</td><td>' + (c.expires ? when(c.expires) : '') + '</td><td>' + (c.active ? 'active' : 'off') + '</td><td class="act"><button type="button" class="btn small ghost" data-pctoggle="' + esc(c.code) + '" data-on="' + (c.active ? 0 : 1) + '">' + (c.active ? 'Switch off' : 'Switch on') + '</button></td></tr>').join('') : '<tr><td colspan="8" class="muted">No codes yet.</td></tr>');
|
||||
$('pcRecent').innerHTML = '<tr><th>When</th><th>Code</th><th>Email</th><th>Credits</th><th>Via</th></tr>'
|
||||
+ (d.recent.length ? d.recent.map(r => '<tr><td>' + new Date(r.ts).toLocaleString() + '</td><td>' + esc(r.code) + '</td><td>' + esc(r.email) + '</td><td>' + r.credits + '</td><td>' + esc(r.via) + '</td></tr>').join('') : '<tr><td colspan="5" class="muted">No redemptions yet.</td></tr>');
|
||||
document.querySelectorAll('[data-pctoggle]').forEach(b => b.addEventListener('click', async () => {
|
||||
try { await api('/api/admin/promos', { code: b.dataset.pctoggle, active: b.dataset.on === '1' }, 'PATCH'); loadPromos(); } catch (e) { IAP.status(e.message, 'bad'); }
|
||||
}));
|
||||
}
|
||||
if ($('pcSave')) $('pcSave').addEventListener('click', async () => {
|
||||
const msg = $('pcMsg'); msg.hidden = false;
|
||||
try {
|
||||
const r = await api('/api/admin/promos', { code: $('pcCode').value, credits: $('pcCredits').value, partner: $('pcPartner').value, maxUses: $('pcMax').value, expires: $('pcExpires').value || null, active: true });
|
||||
msg.textContent = 'Saved ' + r.code.code + ': ' + r.code.credits + ' credits.'; msg.style.color = 'var(--mint)';
|
||||
$('pcCode').value = ''; $('pcCredits').value = ''; $('pcPartner').value = ''; loadPromos();
|
||||
} catch (e) { msg.textContent = e.message; msg.style.color = '#ff8a8a'; }
|
||||
});
|
||||
async function loadTraffic() {
|
||||
loadPromos().catch(e => IAP.status(e.message, 'bad'));
|
||||
const d = await (await fetch('/api/admin/traffic?days=' + trfDays)).json();
|
||||
if (d.error) throw new Error(d.error);
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const n = v => Number(v || 0).toLocaleString();
|
||||
$('trfSub').textContent = 'last ' + d.days + ' days · ' + n(d.totals.hits) + ' page views · ' + n(d.totals.joinViews) + ' join-page views · ' + n(d.totals.signups) + ' signups · ' + n(d.totals.buyers) + ' buyers';
|
||||
// conversion columns (Marty, 2026-09-13): visits = page views + join-page views; signup rate is per visit,
|
||||
// registered and buyer rates are per signup (what happened to the people who did sign up)
|
||||
const pct = (num, den) => den ? (100 * num / den).toFixed(num && 100 * num / den < 10 ? 1 : 0) + '%' : '<span class="muted">-</span>';
|
||||
$('trfSources').innerHTML = '<tr><th>Source</th><th>Page<br>views</th><th>Join-page<br>views</th><th>Signups</th><th>Visit →<br>signup</th><th>Registered</th><th>Signup →<br>registered</th><th>$20+<br>buyers</th><th>Signup →<br>buyer</th></tr>'
|
||||
+ (d.sources.length ? d.sources.map(s => '<tr><td>' + esc(s.source) + '</td><td>' + n(s.hits) + '</td><td>' + n(s.joinViews) + '</td><td>' + n(s.signups) + '</td><td>' + pct(s.signups, s.hits + s.joinViews) + '</td><td>' + n(s.registered) + '</td><td>' + pct(s.registered, s.signups) + '</td><td>' + n(s.buyers) + '</td><td>' + pct(s.buyers, s.signups) + '</td></tr>').join('') : '<tr><td colspan="9" class="muted">Nothing recorded in this range yet.</td></tr>');
|
||||
$('trfPaths').innerHTML = '<tr><th>Page</th><th>Views</th></tr>' + (d.paths.length ? d.paths.map(p => '<tr><td>' + esc(p.path) + '</td><td>' + n(p.hits) + '</td></tr>').join('') : '<tr><td colspan="2" class="muted">No page views yet.</td></tr>');
|
||||
$('trfAngles').innerHTML = '<tr><th>Angle</th><th>Join-page<br>views</th><th>Signups</th><th>View →<br>signup</th></tr>' + (d.angles.length ? d.angles.map(a => '<tr><td>' + esc(a.angle) + '</td><td>' + n(a.views) + '</td><td>' + n(a.signups) + '</td><td>' + pct(a.signups, a.views) + '</td></tr>').join('') : '<tr><td colspan="4" class="muted">No angle data yet.</td></tr>');
|
||||
$('trfDaily').innerHTML = '<tr><th>Day</th><th>Page views</th><th>Signups</th></tr>' + (d.daily.length ? d.daily.slice().reverse().map(x => '<tr><td>' + esc(x.day) + '</td><td>' + n(x.hits) + '</td><td>' + n(x.signups) + '</td></tr>').join('') : '<tr><td colspan="3" class="muted">Nothing yet.</td></tr>');
|
||||
}
|
||||
// ── blog: coaching articles, public at /blog (Marty, 2026-09-12) ──
|
||||
let blCur = null; // slug being edited, or null for a new one
|
||||
function blCount() {
|
||||
const t = $('blTitle').value.length, e = $('blExcerpt').value.length;
|
||||
$('blTitleCount').textContent = t + '/60' + (t > 60 ? ' (long)' : '');
|
||||
$('blExcCount').textContent = e + ' chars' + (e && (e < 120 || e > 160) ? ' (aim 120-160)' : '');
|
||||
const w = $('blBody').textContent.trim().split(/\s+/).filter(Boolean).length;
|
||||
$('blWords').textContent = w + ' words';
|
||||
}
|
||||
['blTitle', 'blExcerpt'].forEach(id => $(id).addEventListener('input', blCount));
|
||||
$('blBody').addEventListener('input', blCount);
|
||||
$('blTitle').addEventListener('input', () => { if (!blCur && !$('blSlug').dataset.touched) $('blSlug').value = $('blTitle').value.toLowerCase().replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80); });
|
||||
$('blSlug').addEventListener('input', () => { $('blSlug').dataset.touched = '1'; });
|
||||
document.querySelectorAll('.ed-bar [data-bl]').forEach(b => b.addEventListener('click', () => { $('blBody').focus(); document.execCommand(b.dataset.bl, false, null); }));
|
||||
document.querySelectorAll('.ed-bar [data-blblock]').forEach(b => b.addEventListener('click', () => { $('blBody').focus(); document.execCommand('formatBlock', false, b.dataset.blblock); }));
|
||||
$('blLinkBtn').addEventListener('click', async () => {
|
||||
const sel = window.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
|
||||
const u = await IAP.ask({ title: 'Link address', label: 'https://', placeholder: 'https://linkspin-test.saasy.top/join/martbost', ok: 'Insert' });
|
||||
if (u) { $('blBody').focus(); if (range) { sel.removeAllRanges(); sel.addRange(range); } document.execCommand('createLink', false, u); }
|
||||
});
|
||||
$('blImgBtn').addEventListener('click', () => $('blImgFile').click());
|
||||
$('blImgFile').addEventListener('change', async () => {
|
||||
const f = $('blImgFile').files[0]; if (!f) return;
|
||||
try {
|
||||
const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||||
if (r.error) throw new Error(r.error);
|
||||
$('blBody').focus();
|
||||
const html = '<img src="' + r.url + '" alt="">';
|
||||
if (!document.execCommand('insertHTML', false, html)) $('blBody').insertAdjacentHTML('beforeend', html);
|
||||
blCount();
|
||||
} catch (e) { IAP.status(e.message || 'Upload failed.', 'bad'); }
|
||||
$('blImgFile').value = '';
|
||||
});
|
||||
$('blCoverBtn').addEventListener('click', () => $('blCoverFile').click());
|
||||
$('blCoverFile').addEventListener('change', () => upload($('blCoverFile'), $('blCoverInfo'), $('blCover')));
|
||||
$('blHtmlBtn').addEventListener('click', () => {
|
||||
const raw = !$('blHtml').hidden;
|
||||
if (raw) { $('blBody').innerHTML = $('blHtml').value; $('blHtml').hidden = true; $('blBody').hidden = false; }
|
||||
else { $('blHtml').value = $('blBody').innerHTML; $('blBody').hidden = true; $('blHtml').hidden = false; }
|
||||
blCount();
|
||||
});
|
||||
function blBodyHtml() { return $('blHtml').hidden ? $('blBody').innerHTML : $('blHtml').value; }
|
||||
function blMsg(t, bad) { $('blMsg').textContent = t; $('blMsg').hidden = !t; $('blMsg').className = 'small ' + (bad ? 'bad' : 'ok'); }
|
||||
function blOpen(post) {
|
||||
blCur = post ? post.slug : null;
|
||||
$('blogList').hidden = true; $('blogEditor').hidden = false;
|
||||
$('blEdTitle').textContent = post ? 'Edit article' : 'New article';
|
||||
$('blEdSub').textContent = post ? (post.status === 'published' ? 'published ' + when(post.publishedAt) + ' · ' + (post.views || 0) + ' views' : 'draft') : '';
|
||||
$('blTitle').value = post ? post.title : ''; $('blSlug').value = post ? post.slug : ''; delete $('blSlug').dataset.touched;
|
||||
$('blTags').value = post ? post.tags.join(', ') : ''; $('blExcerpt').value = post ? post.excerpt : ''; $('blCover').value = post ? post.cover : ''; $('blCoverInfo').textContent = '';
|
||||
$('blHtml').hidden = true; $('blBody').hidden = false; $('blBody').innerHTML = post ? post.body : '';
|
||||
$('blUnpublish').hidden = !(post && post.status === 'published'); $('blDelete').hidden = !post;
|
||||
$('blPreview').hidden = !post; if (post) $('blPreview').href = '/blog/' + post.slug;
|
||||
$('blPublish').textContent = post && post.status === 'published' ? 'Save and publish' : 'Publish';
|
||||
blMsg(''); blCount(); $('blTitle').focus();
|
||||
}
|
||||
async function blSave(status) {
|
||||
const body = { existingSlug: blCur, title: $('blTitle').value, slug: $('blSlug').value, tags: $('blTags').value, excerpt: $('blExcerpt').value, cover: $('blCover').value, body: blBodyHtml(), status };
|
||||
const r = await api('/api/admin/blog', body);
|
||||
blCur = r.post.slug;
|
||||
$('blSlug').value = r.post.slug; $('blPreview').hidden = false; $('blPreview').href = '/blog/' + r.post.slug; $('blDelete').hidden = false;
|
||||
$('blUnpublish').hidden = r.post.status !== 'published'; $('blPublish').textContent = r.post.status === 'published' ? 'Save and publish' : 'Publish';
|
||||
$('blEdTitle').textContent = 'Edit article';
|
||||
blMsg(r.post.status === 'published' ? 'Published. Live at linkspin-test.saasy.top/blog/' + r.post.slug + (r.syndicating ? ' · posting to X and Instagram now (see the Social column in the list).' : '') : 'Draft saved.');
|
||||
IAP.status(r.post.status === 'published' ? 'Published.' : 'Draft saved.', 'ok');
|
||||
}
|
||||
$('blSaveDraft').addEventListener('click', busy($('blSaveDraft'), () => blSave('draft')));
|
||||
$('blPublish').addEventListener('click', busy($('blPublish'), () => blSave('published')));
|
||||
$('blUnpublish').addEventListener('click', busy($('blUnpublish'), () => blSave('draft')));
|
||||
$('blClose').addEventListener('click', () => { $('blogEditor').hidden = true; $('blogList').hidden = false; loadBlog().catch(e => IAP.status(e.message, 'bad')); });
|
||||
$('blDelete').addEventListener('click', busy($('blDelete'), async () => {
|
||||
if (!blCur) return;
|
||||
if (!await IAP.confirmBox('The page at /blog/' + blCur + ' stops existing. There is no undo.', { title: 'Delete this article?', ok: 'Delete', cancel: 'Keep it' })) return;
|
||||
await api('/api/admin/blog?slug=' + encodeURIComponent(blCur), undefined, 'DELETE');
|
||||
$('blClose').click();
|
||||
}));
|
||||
$('blNew').addEventListener('click', () => blOpen(null));
|
||||
async function loadBlog() {
|
||||
const d = await api('/api/admin/blog');
|
||||
const pub = d.posts.filter(p => p.status === 'published').length;
|
||||
$('blSub').textContent = pub + ' published · ' + (d.posts.length - pub) + ' drafts';
|
||||
const synd = p => { const s = p.syndicated; if (!s) return p.status === 'published' ? '<span class="muted small">not posted</span>' : ''; const r = s.results || {}; const part = ['x', 'instagram'].map(k => r[k] ? (r[k].ok ? k + ' ✓' : k + ' ✗') : k + ' –').join(' · '); return '<span class="small' + (s.done ? '' : ' bad') + '" title="' + esc(Object.values(r).map(v => v.error || '').filter(Boolean).join(' | ') || (s.error || '')) + '">' + part + '</span>'; };
|
||||
$('blSyndNote').hidden = false; $('blSyndNote').textContent = d.syndication ? 'Publishing an article posts it to X (@cryptoteambuild) and Instagram (marketingwithmarty) through Blotato, once per article, with the cover image.' : 'Social syndication is off: no Blotato key on the server.';
|
||||
$('blTable').innerHTML = '<tr><th>Title</th><th>Status</th><th>Social</th><th>Tags</th><th>Views</th><th>Updated</th><th></th></tr>'
|
||||
+ (d.posts.length ? d.posts.map(p => '<tr><td><b>' + esc(p.title) + '</b><br><span class="muted small">/blog/' + esc(p.slug) + '</span></td><td>' + (p.status === 'published' ? '<span class="chip-t on">published</span>' : '<span class="chip-t">draft</span>') + '</td><td>' + synd(p) + '</td><td>' + esc(p.tags.join(', ')) + '</td><td>' + (p.views || 0) + '</td><td>' + when(p.updated) + '</td><td class="act"><button type="button" class="btn small sec" data-bledit="' + esc(p.slug) + '">Edit</button> <a class="btn small sec" href="/blog/' + esc(p.slug) + '" target="_blank" rel="noopener">View</a>' + (p.status === 'published' && d.syndication && !(p.syndicated && p.syndicated.done) ? ' <button type="button" class="btn small sec" data-blsynd="' + esc(p.slug) + '">Post to X + IG</button>' : '') + '</td></tr>').join('')
|
||||
: '<tr><td colspan="7" class="muted">No articles yet. Start with "New article".</td></tr>');
|
||||
$('blTable').querySelectorAll('[data-blsynd]').forEach(b => b.addEventListener('click', busy(b, async () => {
|
||||
const r = await api('/api/admin/blog/syndicate', { slug: b.dataset.blsynd });
|
||||
const res = r.syndicated && r.syndicated.results || {}; const bad = Object.entries(res).filter(([, v]) => !v.ok).map(([k, v]) => k + ': ' + v.error);
|
||||
IAP.status(bad.length ? 'Posted with problems: ' + bad.join(' | ') : 'Posted to X and Instagram.', bad.length ? 'bad' : 'ok'); loadBlog().catch(() => {});
|
||||
})));
|
||||
$('blTable').querySelectorAll('[data-bledit]').forEach(b => b.addEventListener('click', async () => {
|
||||
try { const r = await api('/api/admin/blog?slug=' + encodeURIComponent(b.dataset.bledit)); blOpen(r.post); } catch (e) { IAP.status(e.message, 'bad'); }
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadPnl() {
|
||||
const r = await api('/api/admin/pnl?days=' + pnlDays);
|
||||
const px = r.polUsd || 0;
|
||||
const platUsd = (Number(BigInt(r.platformWei || '0') / 10n ** 14n) / 10000) * px;
|
||||
const months = pnlDays ? pnlDays / 30 : Math.max(1, (r.latest - r.fromBlock) / 43200 / 30);
|
||||
const fixed = (r.fixedMonthlyUsd || 0) * months;
|
||||
$('pnlTiles').innerHTML = [
|
||||
['Packages sold', r.purchases.count, Object.entries(r.purchases.byPackage || {}).map(([k, v]) => v + '×' + k).join(' · ') || '—'],
|
||||
['Gross volume', pol(r.purchases.volumeWei) + ' POL', usdOf(r.purchases.volumeWei, px) + ' at today\'s rate · $' + (r.purchases.usdCents / 100).toLocaleString() + ' at sale'],
|
||||
['Platform (fees + dust + unclaimed)', pol(r.platformWei) + ' POL', usdOf(r.platformWei, px)],
|
||||
['Paid to members', pol(r.memberPayoutsWei) + ' POL', usdOf(r.memberPayoutsWei, px)],
|
||||
['Net after fixed costs', '$' + Math.round(platUsd - fixed).toLocaleString(), 'fixed ' + Math.round(fixed).toLocaleString() + ' over ' + months.toFixed(1) + ' month(s)'],
|
||||
['Pass-ups', r.passedUp.count, r.passedUp.unqualified + ' unqualified · ' + r.passedUp.sendFailed + ' send-failed']
|
||||
].map(t => '<div class="statx"><div><div class="nv" style="font-size:22px">' + esc(String(t[1])) + '</div><div class="lb">' + esc(t[0]) + '</div><span class="chip flat">' + esc(t[2]) + '</span></div></div>').join('');
|
||||
$('pnlSplit').innerHTML = '<tr><th>Line</th><th>POL</th><th>USD now</th></tr>'
|
||||
+ [['Level 1 (50%)', r.byTier[1]], ['Level 2 (20%)', r.byTier[2]], ['Level 3 (10%)', r.byTier[3]], ['Platform (20% + pass-ups)', r.platformWei]].map(x => '<tr><td>' + x[0] + '</td><td class="mono">' + pol(x[1]) + '</td><td class="mono">' + usdOf(x[1], px) + '</td></tr>').join('');
|
||||
const W = r.wallets || {}, B = r.balances || {};
|
||||
$('pnlWallets').innerHTML = '<tr><th>Wallet</th><th>Address</th><th>Balance</th></tr>'
|
||||
+ [['Owner / fee A (Tangem)', W.feeA, B.feeA], ['Fee B', W.feeB, B.feeB], ['Engine (gas)', W.engine, B.engine]].filter(x => x[1]).map(x => '<tr><td>' + x[0] + '</td><td class="mono small">' + esc(x[1]) + '</td><td class="mono">' + (x[2] == null ? '?' : pol(x[2]) + ' POL') + '</td></tr>').join('');
|
||||
$('pnlFixed').value = r.fixedMonthlyUsd || 0;
|
||||
const b = r.burner || {};
|
||||
$('burnerLine').textContent = !b.hasEthers ? 'ethers is not installed in this build.' : !b.keyPresent ? 'No engine key configured (ENGINE_KEY). Burns stay pending until it is set.' : b.mismatch ? 'ENGINE_KEY does not match the contract engine signer. Disabled.' : 'Engine wallet ' + b.address + ' holds ' + pol(b.balanceWei) + ' POL. That is its gas fund, not a cost: one burn uses about 0.003 POL (roughly 48,000 gas), paid by this wallet, never by the member. ' + b.burned + ' burn' + (b.burned === 1 ? '' : 's') + ' since boot' + (b.lastRun ? ' · last check ' + when(b.lastRun) : '') + (b.lastError ? ' · last error: ' + b.lastError : '') + (b.skipped && Object.keys(b.skipped).length ? ' · skipped (needs review): ' + Object.entries(b.skipped).map(([k, v]) => k + ' (' + v + ')').join(', ') : '');
|
||||
}
|
||||
document.querySelectorAll('#pnlPeriods [data-days]').forEach(b => b.addEventListener('click', () => { pnlDays = Number(b.dataset.days); document.querySelectorAll('#pnlPeriods [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadPnl().catch(e => IAP.status(e.message, 'bad')); }));
|
||||
if ($('pnlFixedSave')) $('pnlFixedSave').addEventListener('click', async () => { try { await api('/api/admin/site', { pnlFixedMonthlyUsd: Number($('pnlFixed').value) || 0 }, 'PATCH'); IAP.status('Saved.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } });
|
||||
if ($('burnerRun')) $('burnerRun').addEventListener('click', async () => { try { const r = await api('/api/admin/burner/run', {}); IAP.status('Burner ran: ' + (r.burned || 0) + ' burned.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } });
|
||||
async function loadAudit() {
|
||||
if (!$('audTable')) return;
|
||||
try {
|
||||
$('audSub').textContent = 'checking…';
|
||||
const a = await api('/api/admin/audit');
|
||||
const bad = a.checks.filter(c => !c.ok).length;
|
||||
$('audSub').textContent = (bad ? bad + ' issue' + (bad === 1 ? '' : 's') : 'all counters reconcile') + ' · checked ' + new Date(a.checkedAt).toLocaleTimeString();
|
||||
$('audTable').innerHTML = '<tr><th>Check</th><th>Status</th><th>Detail</th></tr>' + a.checks.map(c => '<tr><td>' + esc(c.name) + '</td><td>' + (c.ok ? '<span class="badge">ok</span>' : '<span class="badge amber">' + c.issues.length + ' issue' + (c.issues.length === 1 ? '' : 's') + '</span>') + '</td><td class="small">' + esc(c.detail) + (c.issues.length ? '<br>' + c.issues.map(esc).join('<br>') : '') + '</td></tr>').join('');
|
||||
} catch (e) { $('audSub').textContent = e.message; }
|
||||
}
|
||||
if ($('audRun')) $('audRun').addEventListener('click', busy($('audRun'), loadAudit));
|
||||
async function loadReports() {
|
||||
loadAudit();
|
||||
const [r, b] = await Promise.all([api('/api/admin/reports'), api('/api/admin/burns')]);
|
||||
const reps = r.reports || [];
|
||||
$('repTable').innerHTML = reps.length ? '<tr><th>When</th><th>Campaign</th><th>Reason</th><th>Note</th><th>By</th><th></th></tr>'
|
||||
+ reps.map(x => '<tr' + (x.resolved ? ' style="opacity:.5"' : '') + '><td class="small muted">' + when(x.ts) + '</td><td>#' + x.campaignId + '</td><td>' + esc(x.reason) + '</td><td>' + esc(x.note || '') + '</td><td class="small">' + esc(x.reporter || 'anon') + '</td>'
|
||||
+ '<td class="act">' + (x.resolved ? 'resolved' : '<button class="btn small sec" data-act="pause" data-id="' + x.campaignId + '">Pause ad</button><button class="btn small" data-resolve="' + x.id + '">Resolve</button>') + '</td></tr>').join('')
|
||||
: '<tr><td class="muted">No reports.</td></tr>';
|
||||
const burns = b.pending || [];
|
||||
$('burnTable').innerHTML = burns.length ? '<tr><th>When</th><th>Member</th><th>Credits</th><th>Ref</th><th>Burn id</th></tr>'
|
||||
+ burns.map(x => '<tr><td class="small muted">' + when(x.ts) + '</td><td>#' + x.memberId + '</td><td class="mono">' + x.amount + '</td><td>' + esc(x.ref) + '</td><td class="mono small">' + esc(x.id) + '</td></tr>').join('')
|
||||
: '<tr><td class="muted">Nothing pending.</td></tr>';
|
||||
}
|
||||
document.addEventListener('click', async e => {
|
||||
const b = e.target.closest('[data-resolve]'); if (!b) return;
|
||||
b.disabled = true;
|
||||
try { await api('/api/admin/reports/' + b.dataset.resolve + '/resolve', {}); IAP.status('Report resolved.', 'ok'); await Promise.all([loadReports(), loadOverview()]); }
|
||||
catch (err) { IAP.status(err.message, 'bad'); b.disabled = false; }
|
||||
});
|
||||
|
||||
// ── settings: graphical editors (follow-up emails, rates, site settings) ──
|
||||
let dripSeq = [], ratesObj = {}, siteObj = {};
|
||||
let lastFocusedField = null;
|
||||
document.addEventListener('focusin', e => { if (e.target && (e.target.matches('textarea.ds-body') || e.target.matches('input.ds-subject'))) lastFocusedField = e.target; });
|
||||
document.addEventListener('click', e => {
|
||||
const c = e.target.closest('[data-ph]'); if (!c) return;
|
||||
const el = lastFocusedField; if (!el) { IAP.status('Click into a subject or body first, then the chip.', 'bad'); return; }
|
||||
const ph = c.dataset.ph, st = el.selectionStart || 0, en = el.selectionEnd || st;
|
||||
el.value = el.value.slice(0, st) + ph + el.value.slice(en);
|
||||
el.focus(); el.selectionStart = el.selectionEnd = st + ph.length;
|
||||
el.dispatchEvent(new Event('input'));
|
||||
});
|
||||
const whenLabel = h => { h = Number(h) || 0; if (h < 24) return h + ' hour' + (h === 1 ? '' : 's') + ' after sign-up'; const d = h / 24; return (Number.isInteger(d) ? d : d.toFixed(1)) + ' day' + (d === 1 ? '' : 's') + ' after sign-up'; };
|
||||
function drawDrip() {
|
||||
const wrap = $('dripSteps');
|
||||
wrap.innerHTML = dripSeq.map((st, i) => '<div class="drip-step" data-i="' + i + '">'
|
||||
+ '<div class="ds-head"><span class="ds-n">EMAIL ' + (i + 1) + '</span>'
|
||||
+ '<span class="ds-when">send <input type="number" min="1" class="ds-hours" value="' + esc(st.hours) + '"> hours after sign-up <b class="ds-whenlbl">(' + esc(whenLabel(st.hours)) + ')</b></span>'
|
||||
+ '<span class="ds-tools"><button type="button" class="btn small sec" data-act="up" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button type="button" class="btn small sec" data-act="down" ' + (i === dripSeq.length - 1 ? 'disabled' : '') + '>↓</button>'
|
||||
+ '<button type="button" class="btn small sec" data-act="test">Send to me</button><button type="button" class="btn small sec" data-act="remove">Remove</button></span></div>'
|
||||
+ '<input class="ds-subject" placeholder="Subject line" maxlength="150" value="' + esc(st.subject) + '">'
|
||||
+ '<textarea class="ds-body" placeholder="Plain-text email body">' + esc(st.body) + '</textarea>'
|
||||
+ '</div>').join('') || '<p class="muted small">No emails yet. Add one below.</p>';
|
||||
}
|
||||
function readDrip() {
|
||||
return [...document.querySelectorAll('#dripSteps .drip-step')].map(card => ({
|
||||
hours: Number(card.querySelector('.ds-hours').value) || 0,
|
||||
subject: card.querySelector('.ds-subject').value.trim(),
|
||||
body: card.querySelector('.ds-body').value.trim()
|
||||
}));
|
||||
}
|
||||
$('dripSteps').addEventListener('input', e => {
|
||||
if (e.target.classList.contains('ds-hours')) { const l = e.target.closest('.ds-when').querySelector('.ds-whenlbl'); if (l) l.textContent = '(' + whenLabel(e.target.value) + ')'; }
|
||||
});
|
||||
$('dripSteps').addEventListener('click', async e => {
|
||||
const b = e.target.closest('[data-act]'); if (!b) return;
|
||||
const card = b.closest('.drip-step'), i = Number(card.dataset.i);
|
||||
dripSeq = readDrip();
|
||||
if (b.dataset.act === 'remove') { if (!confirm('Remove email ' + (i + 1) + '?')) return; dripSeq.splice(i, 1); drawDrip(); return; }
|
||||
if (b.dataset.act === 'up' && i > 0) { [dripSeq[i - 1], dripSeq[i]] = [dripSeq[i], dripSeq[i - 1]]; drawDrip(); return; }
|
||||
if (b.dataset.act === 'down' && i < dripSeq.length - 1) { [dripSeq[i + 1], dripSeq[i]] = [dripSeq[i], dripSeq[i + 1]]; drawDrip(); return; }
|
||||
if (b.dataset.act === 'test') {
|
||||
b.disabled = true;
|
||||
try { await saveDrip(); await api('/api/admin/drip/test', { step: i }); IAP.status('Email ' + (i + 1) + ' sent to your inbox.', 'ok'); }
|
||||
catch (err) { IAP.status(err.message, 'bad'); }
|
||||
b.disabled = false;
|
||||
}
|
||||
});
|
||||
async function saveDrip() {
|
||||
$('dripErr').hidden = true;
|
||||
const seq = readDrip();
|
||||
const r = await api('/api/admin/drip', { sequence: seq }, 'PATCH').catch(err => { $('dripErr').textContent = err.message; $('dripErr').hidden = false; throw err; });
|
||||
dripSeq = r.sequence; drawDrip();
|
||||
return r;
|
||||
}
|
||||
$('dripSave').addEventListener('click', busy($('dripSave'), async () => { await saveDrip(); IAP.status('Sequence saved.', 'ok'); await loadSettings(); }));
|
||||
$('dripAdd').addEventListener('click', () => {
|
||||
dripSeq = readDrip();
|
||||
const last = dripSeq[dripSeq.length - 1];
|
||||
dripSeq.push({ hours: last ? Number(last.hours) + 48 : 24, subject: '', body: '\n\nMarty\n\n{{footer}}' });
|
||||
drawDrip();
|
||||
const cards = document.querySelectorAll('#dripSteps .drip-step'); const c = cards[cards.length - 1]; if (c) { c.scrollIntoView({ behavior: 'smooth', block: 'center' }); c.querySelector('.ds-subject').focus(); }
|
||||
});
|
||||
$('dripReset').addEventListener('click', busy($('dripReset'), async () => {
|
||||
if (!confirm('Replace the saved sequence with the built-in defaults?')) return;
|
||||
const r = await api('/api/admin/drip', { reset: true }, 'PATCH');
|
||||
dripSeq = r.sequence; drawDrip(); IAP.status('Defaults restored.', 'ok'); await loadSettings();
|
||||
}));
|
||||
|
||||
// rates: labels + hints for the known keys; anything unknown still gets a plain field
|
||||
const RATE_META = {
|
||||
bannerBatch: ['Banner: views per batch', 'impressions counted before a banner campaign is charged'],
|
||||
bannerCreditsPerBatch: ['Banner: credits per batch', 'charged to the advertiser per batch'],
|
||||
textBatch: ['Text ad: views per batch', ''], textCreditsPerBatch: ['Text ad: credits per batch', ''],
|
||||
loginCreditsPerDay: ['Login ad: credits per day', 'flat daily charge while active'],
|
||||
loginDwellSeconds: ['Login ad: seconds shown', 'full-screen interstitial after sign-in'],
|
||||
burnBatchMin: ['On-chain burn batch (credits)', 'accrued spend is burned once it reaches this'],
|
||||
welcomeCredits: ['Welcome credits', 'granted after the welcome tour'],
|
||||
dailyViewTarget: ['Daily view set (ads)', 'ads a member views for the daily claim'],
|
||||
dailyClaimCredits: ['Daily claim (credits)', 'paid when the set is complete'],
|
||||
viewDwellSeconds: ['Ad view: seconds per ad', 'the countdown; server-enforced'],
|
||||
soloCostPerRecipient: ['Solo ad: credits per recipient', ''], soloMinRecipients: ['Solo ad: minimum recipients', ''],
|
||||
soloReadCredits: ['Solo ad: reader reward (credits)', ''], soloReadCapPerDay: ['Solo ad: rewarded reads per day', ''], soloReadDwellSeconds: ['Solo ad: seconds to read', ''],
|
||||
videoWatchCapPerDay: ['Video: rewarded watches per day', ''],
|
||||
featuredPerDay: ['Featured link: credits per day', ''], featuredSlotsPerDay: ['Featured link: slots per day', ''], featuredWindowDays: ['Featured link: booking window (days)', ''],
|
||||
featuredDurations: ['Featured link: durations offered (days)', 'comma-separated'],
|
||||
visitCostPerVisit: ['Verified visit: credits per visit', ''], visitMinPack: ['Verified visit: smallest pack', ''], visitReward: ['Verified visit: viewer reward (credits)', ''], visitDwellSeconds: ['Verified visit: seconds on site', ''], visitCapPerDay: ['Verified visit: rewarded visits per day', ''],
|
||||
videoTiers: ['Video ad tiers', 'watch length → advertiser cost → viewer reward'],
|
||||
milestoneBonus: ['Milestone bonuses (credits)', 'one-time, when a member reaches each step']
|
||||
};
|
||||
const humanize = k => k.replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase());
|
||||
function drawRates() {
|
||||
const wrap = $('ratesForm'); const html = [];
|
||||
for (const [k, v] of Object.entries(ratesObj)) {
|
||||
const [label, hint] = RATE_META[k] || [humanize(k), ''];
|
||||
if (typeof v === 'number') html.push('<div class="rf"><label>' + esc(label) + '</label><input type="number" step="any" data-rk="' + esc(k) + '" value="' + esc(v) + '">' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '</div>');
|
||||
else if (typeof v === 'boolean') html.push('<div class="rf"><label>' + esc(label) + '</label><label class="small"><input type="checkbox" data-rk="' + esc(k) + '"' + (v ? ' checked' : '') + ' style="width:auto"> on</label></div>');
|
||||
else if (Array.isArray(v) && v.every(x => typeof x === 'number')) html.push('<div class="rf"><label>' + esc(label) + '</label><input data-rk="' + esc(k) + '" data-kind="numlist" value="' + esc(v.join(', ')) + '">' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '</div>');
|
||||
else if (Array.isArray(v) && v.every(x => x && typeof x === 'object')) {
|
||||
const cols = [...new Set(v.flatMap(x => Object.keys(x)))];
|
||||
html.push('<div class="rf wide"><label>' + esc(label) + '</label>' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '')
|
||||
+ '<table class="tiers" data-rk="' + esc(k) + '" data-kind="table"><tr>' + cols.map(c => '<th>' + esc(c) + '</th>').join('') + '</tr>'
|
||||
+ v.map((row, i) => '<tr>' + cols.map(c => '<td><input type="number" step="any" data-col="' + esc(c) + '" value="' + esc(row[c] == null ? '' : row[c]) + '"></td>').join('') + '</tr>').join('') + '</table></div>');
|
||||
} else if (v && typeof v === 'object') {
|
||||
html.push('<div class="rf wide"><label>' + esc(label) + '</label>' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '<div class="sub-grid" data-rk="' + esc(k) + '" data-kind="object">'
|
||||
+ Object.entries(v).map(([sk, sv]) => '<label>' + esc(humanize(sk)) + '<input type="number" step="any" data-sub="' + esc(sk) + '" value="' + esc(sv) + '"></label>').join('') + '</div></div>');
|
||||
} else html.push('<div class="rf"><label>' + esc(label) + '</label><input data-rk="' + esc(k) + '" value="' + esc(v == null ? '' : v) + '"></div>');
|
||||
}
|
||||
wrap.innerHTML = html.join('');
|
||||
}
|
||||
function readRates() {
|
||||
const out = {};
|
||||
document.querySelectorAll('#ratesForm [data-rk]').forEach(el => {
|
||||
const k = el.dataset.rk, kind = el.dataset.kind;
|
||||
if (kind === 'numlist') out[k] = el.value.split(/[\s,]+/).filter(Boolean).map(Number).filter(n => !isNaN(n));
|
||||
else if (kind === 'table') out[k] = [...el.querySelectorAll('tr')].slice(1).map(tr => { const o = {}; tr.querySelectorAll('input[data-col]').forEach(i => { o[i.dataset.col] = Number(i.value); }); return o; });
|
||||
else if (kind === 'object') { const o = {}; el.querySelectorAll('input[data-sub]').forEach(i => { o[i.dataset.sub] = Number(i.value); }); out[k] = o; }
|
||||
else if (el.type === 'checkbox') out[k] = !!el.checked;
|
||||
else if (el.type === 'number') out[k] = Number(el.value);
|
||||
else out[k] = el.value;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
$('ratesSave').addEventListener('click', busy($('ratesSave'), async () => {
|
||||
$('ratesErr').hidden = true;
|
||||
try { const r = await api('/api/admin/rates', readRates(), 'PATCH'); ratesObj = r.rates || readRates(); drawRates(); IAP.status('Rates saved.', 'ok'); }
|
||||
catch (e) { $('ratesErr').textContent = e.message; $('ratesErr').hidden = false; }
|
||||
}));
|
||||
|
||||
// site settings: key / value rows; booleans as checkboxes, numbers stay numbers
|
||||
const SITE_META = { noPayoutIds: 'No-payout positions (member #s, comma): linkage only, no buys from them, no joins routed under them', siteName: 'Site name', tagline: 'Tagline', rehearsal: 'Testnet rehearsal banner', defaultSponsorId: 'Fallback sponsor (member #)', walletConnectProjectId: 'WalletConnect project id', moonpayPublicKey: 'MoonPay public key', telegramBotToken: 'Telegram proof feed: bot token', telegramChatId: 'Telegram proof feed: chat id', telegramTopicId: 'Telegram proof feed: topic id (optional)', telegramEvents: 'Telegram proof feed: events (payouts | payouts+purchases | all)', telegramCtaUrl: 'Telegram proof feed: join link under each post', aiCreditsPerGen: 'AI Copy Engine: credits per generation after the free allowance', aiFreeSurge: 'AI Copy Engine: free generations a month at Surge', aiFreeCircuit: 'AI Copy Engine: free generations a month at Circuit', aiFreeNexus: 'AI Copy Engine: free generations a month at Nexus', snapshotEnabled: 'Daily growth snapshot to Telegram (1/0)', snapshotHourUtc: 'Daily growth snapshot: hour (UTC; 14 = 9 AM Central)', snapshotTargets: 'Daily growth snapshot: targets (feed = proof channel, echo = shared payments topic; comma list)', pipelineMode: 'Pipeline board: off (coming soon card) | preview (admin account only) | on (everyone)', pipelineEta: 'Pipeline: opening date shown on the coming-soon card (e.g. Sep 28)', memberWeeklyEmail: 'Weekly member email to everyone active (1) or only sponsors with a line (0)', leaderboardWeeklyPrize: 'Leaderboard: weekly prize text (optional; blank shows the credit ladder)', leaderboardMonthlyPrize: 'Leaderboard: monthly prize text (optional)', leaderboardWeeklyCredits: 'Leaderboard: weekly credits for 1st,2nd,3rd… (e.g. 1000,500,250; blank = none)', leaderboardMonthlyCredits: 'Leaderboard: monthly credits for 1st,2nd,3rd… (e.g. 5000,2500,1000)', leaderboardAnnounceGeneral: 'Leaderboard: announce winners in the main group too (1/0)', telegramEchoChatId: 'Telegram echo (shared payments topic): chat id', telegramEchoTopicId: 'Telegram echo: topic id', telegramEchoEvents: 'Telegram echo: events (payouts | payouts+purchases | all)', legacyCreditsAdvertiser: 'Legacy welcome credits: former advertisers', legacyCreditsEarner: 'Legacy welcome credits: former earners', pnlFixedMonthlyUsd: 'P&L: fixed monthly cost (USD)' };
|
||||
function drawSite() {
|
||||
const wrap = $('siteForm');
|
||||
wrap.innerHTML = Object.entries(siteObj).map(([k, v]) => '<div class="kv-row"><span class="k" title="' + esc(k) + '">' + esc(SITE_META[k] || humanize(k)) + '</span>'
|
||||
+ (typeof v === 'boolean' ? '<input type="checkbox" data-sk="' + esc(k) + '"' + (v ? ' checked' : '') + '>'
|
||||
: typeof v === 'number' ? '<input type="number" step="any" data-sk="' + esc(k) + '" value="' + esc(v) + '">'
|
||||
: '<input data-sk="' + esc(k) + '" value="' + esc(typeof v === 'object' ? JSON.stringify(v) : (v == null ? '' : v)) + '">')
|
||||
+ '<button type="button" class="btn small sec" data-sdel="' + esc(k) + '">Clear</button></div>').join('') || '<p class="muted small">No settings saved yet.</p>';
|
||||
}
|
||||
function readSite() {
|
||||
const out = {};
|
||||
document.querySelectorAll('#siteForm [data-sk]').forEach(el => {
|
||||
const k = el.dataset.sk;
|
||||
if (el.type === 'checkbox') out[k] = !!el.checked;
|
||||
else if (el.type === 'number') out[k] = Number(el.value);
|
||||
else { const v = el.value; if (/^[\[{]/.test(v)) { try { out[k] = JSON.parse(v); return; } catch (e) {} } out[k] = v; }
|
||||
});
|
||||
return out;
|
||||
}
|
||||
$('siteForm').addEventListener('click', e => {
|
||||
const b = e.target.closest('[data-sdel]'); if (!b) return;
|
||||
siteObj = readSite(); siteObj[b.dataset.sdel] = ''; drawSite();
|
||||
});
|
||||
$('siteAddKey').addEventListener('click', () => {
|
||||
const k = $('siteNewKey').value.trim(); if (!/^[A-Za-z][A-Za-z0-9_]{0,40}$/.test(k)) { IAP.status('Setting names are letters and numbers, no spaces.', 'bad'); return; }
|
||||
siteObj = readSite(); if (!(k in siteObj)) siteObj[k] = ''; $('siteNewKey').value = ''; drawSite();
|
||||
const el = document.querySelector('#siteForm [data-sk="' + k + '"]'); if (el) el.focus();
|
||||
});
|
||||
$('siteSave').addEventListener('click', busy($('siteSave'), async () => {
|
||||
$('siteErr').hidden = true;
|
||||
try { const r = await api('/api/admin/site', readSite(), 'PATCH'); siteObj = r.site || readSite(); drawSite(); IAP.status('Site settings saved.', 'ok'); }
|
||||
catch (e) { $('siteErr').textContent = e.message; $('siteErr').hidden = false; }
|
||||
}));
|
||||
|
||||
async function loadSettings() {
|
||||
const [r, s, d] = await Promise.all([api('/api/admin/rates'), api('/api/admin/site'), api('/api/admin/drip')]);
|
||||
ratesObj = r.rates || {}; drawRates();
|
||||
siteObj = s.site || {}; drawSite();
|
||||
dripSeq = d.sequence || []; drawDrip();
|
||||
const st = d.stats || {};
|
||||
$('dripSub').textContent = (st.active || 0) + ' in flight · ' + (st.done || 0) + ' finished · ' + (st.unsubscribed || 0) + ' unsubscribed' + (d.mailReady ? '' : ' · NO MAIL KEY: nothing sends');
|
||||
}
|
||||
|
||||
render();
|
||||
})();
|
||||
@@ -0,0 +1,2 @@
|
||||
// public blog pages: the shared nav (with wallet status) and footer, nothing else
|
||||
(function () { try { IAP.renderNav(location.pathname.indexOf('/leaderboard') === 0 ? 'leaderboard' : 'blog'); } catch (e) {} })();
|
||||
@@ -0,0 +1,58 @@
|
||||
// 24/7 assistant widget: floating bubble, slide-up panel, /api/chat.
|
||||
(function () {
|
||||
const root = document.createElement('div');
|
||||
root.id = 'iapChat';
|
||||
root.innerHTML = '<button id="iapChatBtn" aria-label="Chat with us" type="button">💬</button>'
|
||||
+ '<div id="iapChatPanel" hidden>'
|
||||
+ '<div class="ch-head"><b>Ask anything</b><span class="ch-sub">Real answers, around the clock</span>'
|
||||
+ '<button id="iapChatClose" aria-label="Close chat" type="button">×</button></div>'
|
||||
+ '<div class="ch-msgs" id="iapChatMsgs">'
|
||||
+ '<div class="ch-m bot">Hey. Ask me how the payments work, what the packages buy, or anything else. Straight answers only, no income hype.</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="ch-input"><input id="iapChatIn" placeholder="Type your question…" maxlength="600">'
|
||||
+ '<button id="iapChatSend" type="button">Send</button></div>'
|
||||
+ '</div>';
|
||||
document.body.appendChild(root);
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const msgs = $('iapChatMsgs');
|
||||
const input = $('iapChatIn');
|
||||
let history = [];
|
||||
|
||||
const add = (text, who) => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'ch-m ' + who;
|
||||
// linkify plain URLs
|
||||
d.innerHTML = String(text).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]))
|
||||
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
|
||||
msgs.appendChild(d);
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
return d;
|
||||
};
|
||||
async function send() {
|
||||
const q = input.value.trim();
|
||||
if (!q) return;
|
||||
input.value = '';
|
||||
add(q, 'me');
|
||||
const wait = add('…', 'bot');
|
||||
try {
|
||||
const r = await (await fetch('/api/chat', { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: q, history: history.slice(-4).join(' | ') }) })).json();
|
||||
wait.remove();
|
||||
add(r.reply || r.error || 'No answer came back. Try again.', 'bot');
|
||||
history.push('Q: ' + q, 'A: ' + (r.reply || ''));
|
||||
} catch (e) {
|
||||
wait.remove();
|
||||
add('Connection hiccup. Try that again.', 'bot');
|
||||
}
|
||||
}
|
||||
$('iapChatBtn').addEventListener('click', () => {
|
||||
const p = $('iapChatPanel');
|
||||
p.hidden = !p.hidden;
|
||||
if (!p.hidden) input.focus();
|
||||
});
|
||||
$('iapChatClose').addEventListener('click', () => { $('iapChatPanel').hidden = true; });
|
||||
$('iapChatSend').addEventListener('click', send);
|
||||
input.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
|
||||
})();
|
||||
@@ -0,0 +1,248 @@
|
||||
// Shared page runtime: site config, nav, formatting. Zero dependencies.
|
||||
window.IAP = (function () {
|
||||
let config = null;
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
async function getConfig() {
|
||||
if (!config) config = await (await fetch('/api/config')).json();
|
||||
return config;
|
||||
}
|
||||
// POL amounts display with two decimals (rounded half-up), e.g. 523.39
|
||||
function fmtPol(wei) {
|
||||
const cents = (BigInt(wei) + 5000000000000000n) / 10000000000000000n; // wei -> hundredths of a POL
|
||||
const s = cents.toString().padStart(3, '0');
|
||||
return s.slice(0, -2) + '.' + s.slice(-2);
|
||||
}
|
||||
const fmtUsd = cents => '$' + (cents / 100).toFixed(2);
|
||||
|
||||
function status(msg, cls) {
|
||||
let el = $('status');
|
||||
if (!el) { el = document.createElement('div'); el.id = 'status'; document.body.appendChild(el); }
|
||||
el.textContent = msg; el.className = cls || ''; el.hidden = false;
|
||||
clearTimeout(status._t);
|
||||
if (cls === 'ok') status._t = setTimeout(() => { el.hidden = true; }, 6000);
|
||||
}
|
||||
|
||||
async function renderNav(active) {
|
||||
const c = await getConfig();
|
||||
const nav = document.createElement('nav');
|
||||
nav.innerHTML = '<div class="wrap">'
|
||||
+ '<span class="logo-wrap"><a class="logo" href="/"><img src="/logo.png" alt="LinkSpin" 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="/#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>';
|
||||
document.body.prepend(nav);
|
||||
if (c.rehearsal) {
|
||||
const b = document.createElement('div');
|
||||
b.className = 'rehearsal';
|
||||
b.innerHTML = '<b>Testnet rehearsal</b>: running on ' + c.chainName + '. Purchases use valueless test POL while we prove every payout in public.';
|
||||
document.body.prepend(b);
|
||||
}
|
||||
const a = nav.querySelector('[data-p="' + active + '"]');
|
||||
if (a) a.className = 'active';
|
||||
refreshNavWallet();
|
||||
renderFooter();
|
||||
}
|
||||
function renderFooter() {
|
||||
if (document.getElementById('iapFooter')) return;
|
||||
const f = document.createElement('footer'); f.id = 'iapFooter';
|
||||
f.style.cssText = 'border-top:1px solid var(--line);margin-top:48px;padding:26px 22px;text-align:center;color:var(--muted);font-size:13px';
|
||||
f.innerHTML = '<div>© ' + new Date().getFullYear() + ' LinkSpin</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="/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);
|
||||
}
|
||||
async function refreshNavWallet() {
|
||||
try {
|
||||
const me = await (await fetch('/api/me')).json();
|
||||
const el = $('navWallet');
|
||||
if (!el) return;
|
||||
if (me.signedIn) {
|
||||
// identity order: username, then email, then wallet
|
||||
const who = me.username
|
||||
? '<b>@' + String(me.username).replace(/[&<>]/g, '') + '</b>'
|
||||
: (me.email ? String(me.email).replace(/[&<>]/g, '')
|
||||
: (me.address ? '<span class="mono">' + me.address.slice(0, 6) + '…' + me.address.slice(-4) + '</span>' : 'signed in'));
|
||||
el.innerHTML = (me.memberId ? '<span class="badge">member #' + me.memberId + '</span> ' : '') + who;
|
||||
} else {
|
||||
el.innerHTML = '<a href="/my">Sign in</a>';
|
||||
}
|
||||
return me;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
function describeEvent(ev, c) {
|
||||
const pol = w => fmtPol(w) + ' POL';
|
||||
// real people, not numbers: use usernames when the site knows them
|
||||
const nm = id => (ev.names && ev.names[id])
|
||||
? String(ev.names[id]).replace(/[&<>]/g, '')
|
||||
: 'member #' + id;
|
||||
switch (ev.type) {
|
||||
case 'Purchase': return '🧾 ' + nm(ev.buyerId) + ' bought package #' + ev.productId
|
||||
+ ' (' + fmtUsd(ev.priceCents) + ') for ' + pol(ev.paidWei) + ' → +' + ev.creditAmount.toLocaleString() + ' credits';
|
||||
case 'TierPaid': return '💸 level ' + ev.tier + ' payout → ' + nm(ev.recipientId) + ': ' + pol(ev.amountWei)
|
||||
+ (ev.hops ? ' (passed up ' + ev.hops + ')' : '');
|
||||
case 'PassedUp': return '↷ level ' + ev.tier + ' passed over ' + nm(ev.skippedId) + ' (' + ev.reason + ')';
|
||||
case 'AdminPaid': return '🏛 platform fee settled: ' + pol(ev.amountWei);
|
||||
case 'BuyerCounted': return '⭐ ' + nm(ev.sponsorId) + ' now has ' + ev.newCount + ' qualifying buyer(s)';
|
||||
case 'MemberActivated': return '👤 ' + nm(ev.id) + ' activated a payout wallet';
|
||||
case 'AwardPaid': return '🎁 award: ' + pol(ev.amountWei) + ' → ' + nm(ev.toId);
|
||||
case 'CreditsConsumed': return '📣 ' + nm(ev.memberId) + ' ran ads: −' + ev.amount.toLocaleString() + ' credits';
|
||||
case 'PriceCached': return '🔮 oracle price refreshed';
|
||||
case 'FallbackPriceUsed': return '🔮 cached price bridged an oracle gap';
|
||||
default: return '· ' + ev.type;
|
||||
}
|
||||
}
|
||||
function feedRow(ev, c) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'row t-' + ev.type;
|
||||
const when = ev.ts ? new Date(ev.ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
|
||||
div.innerHTML = (when ? '<span class="when" title="' + new Date(ev.ts).toLocaleString() + '">' + when + '</span>' : '') + '<span>' + describeEvent(ev, c) + '</span>'
|
||||
+ (c.explorer
|
||||
? '<span class="tx"><a target="_blank" rel="noopener" href="' + c.explorer + '/tx/' + ev.tx + '">verify ↗</a></span>'
|
||||
: '<span class="tx"><a href="/tx/' + ev.tx + '">verify ↗</a></span>'); // built-in viewer when the chain has no public explorer
|
||||
return div;
|
||||
}
|
||||
// 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) {
|
||||
if (!campaignId) return;
|
||||
const reason = (prompt('Report this ad. Reason: broken, inappropriate, spam, scam, or other', 'broken') || '').trim().toLowerCase();
|
||||
if (!reason) return;
|
||||
const note = prompt('Anything to add? (optional)') || '';
|
||||
fetch('/api/report-ad', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ campaignId, reason, note }) })
|
||||
.then(() => status('Thanks — this ad was reported to the admin for review.', 'ok'))
|
||||
.catch(() => status('Could not send the report. Try again.', 'bad'));
|
||||
}
|
||||
const reportTag = ad => ' <a class="ad-report small muted" href="#" data-cid="' + ad.id + '" style="margin-left:8px">⚠ report</a>';
|
||||
function wireReport(el) {
|
||||
const rl = el.querySelector('.ad-report');
|
||||
if (rl) rl.addEventListener('click', e => { e.preventDefault(); reportAd(Number(rl.dataset.cid)); });
|
||||
}
|
||||
async function adSlot(type, elId, opts) {
|
||||
try {
|
||||
const q = '/api/ads/slot?type=' + type + (opts && opts.width ? '&w=' + opts.width + '&h=' + opts.height : '');
|
||||
const { ad } = await (await fetch(q)).json();
|
||||
const el = $(elId);
|
||||
if (!ad || !el) return;
|
||||
el.hidden = false;
|
||||
if (ad.imageUrl) {
|
||||
el.style.textAlign = 'center';
|
||||
el.innerHTML = '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow">'
|
||||
+ '<img src="' + ad.imageUrl + '" alt="advertisement" style="max-width:min(100%,728px);height:auto;display:block;margin:0 auto;border-radius:8px"></a>'
|
||||
+ '<div class="small muted">member ad' + reportTag(ad) + '</div>';
|
||||
} else {
|
||||
el.innerHTML = '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow"><b>' + ad.title + '</b>'
|
||||
+ (ad.body ? ' · ' + ad.body : '') + '</a> <span class="small muted">member ad' + reportTag(ad) + '</span>';
|
||||
}
|
||||
wireReport(el);
|
||||
el.hidden = false;
|
||||
} catch (e) {}
|
||||
}
|
||||
// ── sign-up code request with the invisible guard fields (form age + honeypot)
|
||||
// and the icon check the server asks for only after an IP trips a limit ──
|
||||
const FORM_TS = Date.now();
|
||||
function iconCheck(host, ch, note) {
|
||||
return new Promise(resolve => {
|
||||
host.hidden = false;
|
||||
host.innerHTML = '<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 || '') : '';
|
||||
async function requestCode(email, opts) {
|
||||
const o = opts || {};
|
||||
let pick = null;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const r = await (await fetch('/api/auth/email/start', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, fts: FORM_TS, hp_field_x9: hpValue(o.honeypot), pick }) })).json();
|
||||
if (r.challenge && o.host) { pick = await iconCheck(o.host, r.challenge, r.error); continue; }
|
||||
if (r.error) throw new Error(r.error);
|
||||
return r;
|
||||
}
|
||||
throw new Error('Could not verify. Refresh the page and try again.');
|
||||
}
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
// ── founding-week checklist, read from the live account. Shared by /launch and
|
||||
// the dashboard mark. Two items are the member's own call and persist locally.
|
||||
const LAUNCH_KEY = 'iap.launch.manual';
|
||||
const manualSet = () => { try { return new Set(JSON.parse(localStorage.getItem(LAUNCH_KEY) || '[]')); } catch (e) { return new Set(); } };
|
||||
function launchToggle(key) { const s = manualSet(); if (s.has(key)) s.delete(key); else s.add(key); try { localStorage.setItem(LAUNCH_KEY, JSON.stringify([...s])); } catch (e) {} }
|
||||
function launchChecks(me) {
|
||||
const m = me || {}, man = manualSet();
|
||||
const bc = Number(m.buyerCount || 0), refs = (m.referrals || []).length;
|
||||
return [
|
||||
{ key: 'username', title: 'Pick your username', done: !!m.username, href: '/my#profile', cta: 'Profile',
|
||||
how: 'Profile tab. It becomes your invite link and your public page, and it is permanent.',
|
||||
why: 'Every link, banner and video you hand out this week carries it. Change it later and the links you already sent die.' },
|
||||
{ key: 'wallet', title: 'Link your wallet', done: !!m.address, href: '/my#wallet', cta: 'Wallet',
|
||||
how: 'Wallet tab, Connect, sign the free message. MetaMask recommended. Never held crypto? The wallet guide in Training walks through buying POL with a card.',
|
||||
why: 'Payouts go to this address. No wallet, nowhere to pay you.' },
|
||||
{ key: 'payouts', title: 'Switch on payouts', done: !!m.memberId, href: '/my#wallet', cta: 'Wallet',
|
||||
how: 'Wallet tab, one small transaction. It registers your address with the contract.',
|
||||
why: 'The contract binds each buyer to their sponsor at their first purchase. If payouts are off when your first person buys, that commission is not yours.' },
|
||||
{ key: 'level2', title: 'Qualify: open level 2', done: bc >= 2, href: '/my#buy', cta: 'Buy packages',
|
||||
how: 'Two of your people buy a $20 or more package. Or use Qualified Start: add two positions from extra wallets in your own MetaMask and buy a $20 package from each (about $23 of POL in each wallet).',
|
||||
why: 'Until you have two qualifying buyers, every level 2 payment from your team climbs past you.',
|
||||
note: 'Qualifying buyers so far: <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,21 @@
|
||||
// Contract page: inject live address, chain, explorer + verified-source links.
|
||||
(async function () {
|
||||
await IAP.renderNav('contract');
|
||||
const c = await IAP.getConfig();
|
||||
const $ = IAP.$;
|
||||
$('cAddr').textContent = c.contract.slice(0, 10) + '…' + c.contract.slice(-6);
|
||||
$('cChain').textContent = c.chainName;
|
||||
$('mockAddr').textContent = c.contract.slice(0, 18) + '…';
|
||||
if (c.explorer) {
|
||||
$('lnkExplorer').href = c.explorer + '/address/' + c.contract + '#code';
|
||||
const src = 'https://repo.sourcify.dev/contracts/full_match/' + c.chainId + '/' + c.contract + '/';
|
||||
$('lnkSource').href = src;
|
||||
$('lnkSource2').href = src;
|
||||
} else {
|
||||
// fallback if explorer unset: point straight at the Sourcify-verified source
|
||||
const src = 'https://repo.sourcify.dev/contracts/full_match/137/0xBE1ECA72AFF47d13D8E907e523F55eB9e2d365E0/';
|
||||
$('lnkExplorer').href = src;
|
||||
$('lnkSource').href = src;
|
||||
$('lnkSource2').href = src;
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,149 @@
|
||||
// Landing page: live ladder, buy buttons, sponsor attribution line.
|
||||
(async function () {
|
||||
await IAP.renderNav('home');
|
||||
const c = await IAP.getConfig();
|
||||
IAP.$('contractLink').href = c.explorer + '/address/' + c.contract;
|
||||
|
||||
const sp = await (await fetch('/api/sponsor')).json();
|
||||
if (sp.invited) {
|
||||
const el = IAP.$('sponsorLine');
|
||||
el.hidden = false;
|
||||
el.textContent = (sp.sponsorId ? 'You were invited by member #' + sp.sponsorId + '.' : 'You arrived through a member’s invite.')
|
||||
+ ' Your purchases pay their team, and your own link will do the same for you.';
|
||||
}
|
||||
|
||||
async function loadLadder() {
|
||||
const { products } = await (await fetch('/api/catalog')).json();
|
||||
const wrap = document.getElementById('tiles');
|
||||
wrap.innerHTML = '';
|
||||
const NAMES = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' };
|
||||
for (const p of products) {
|
||||
const bonus = p.creditAmount - p.priceCents; // credits above 1cr/cent = bulk bonus
|
||||
const div = document.createElement('div');
|
||||
div.className = 'tile' + (p.priceCents === 5000 ? ' hot' : '');
|
||||
div.innerHTML = '<div class="name">' + (NAMES[p.id] || 'Package ' + p.id) + '</div>'
|
||||
+ '<div class="price">$' + Math.round(p.priceCents / 100) + '</div>'
|
||||
+ '<div class="cr">' + p.creditAmount.toLocaleString() + ' credits</div>'
|
||||
+ '<div class="bonus">' + (bonus > 0 ? '+' + bonus.toLocaleString() + ' bonus credits' : ' ') + '</div>'
|
||||
+ '<div class="pol">' + (p.costWei ? IAP.fmtPol(p.costWei) + ' POL right now' : 'paused') + '</div>'
|
||||
+ '<button class="btn small" data-id="' + p.id + '" data-cost="' + (p.costWei || '') + '"'
|
||||
+ (p.costWei ? '' : ' disabled') + '>Buy</button>';
|
||||
wrap.appendChild(div);
|
||||
}
|
||||
wrap.querySelectorAll('button[data-id]').forEach(b => b.addEventListener('click', () => buyPack(b)));
|
||||
}
|
||||
async function buyPack(btn) {
|
||||
try {
|
||||
btn.disabled = true;
|
||||
// email members get their wallet linked to the account at buy time
|
||||
const me = await (await fetch('/api/me')).json();
|
||||
if (me.signedIn && me.email && !me.address) {
|
||||
IAP.status('First, a free signature links your wallet to your account…');
|
||||
await IAPWallet.signIn();
|
||||
}
|
||||
IAP.status('Confirm the purchase in your wallet…');
|
||||
// resolve the sponsor at buy time: a code referrer who activated since
|
||||
// page load still gets locked in
|
||||
const spNow = await (await fetch('/api/sponsor')).json();
|
||||
const r = await IAPWallet.buy(Number(btn.dataset.id), spNow.sponsorId || 0, btn.dataset.cost);
|
||||
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.');
|
||||
IAP.status('Purchase settled on-chain. Credits are yours, payouts delivered. Watch it on the ledger.', 'ok');
|
||||
IAP.refreshNavWallet();
|
||||
} catch (e) {
|
||||
IAP.status('Purchase failed: ' + (e.message || e), 'bad');
|
||||
} finally { btn.disabled = false; }
|
||||
}
|
||||
async function loadStats() {
|
||||
try {
|
||||
const s = await (await fetch('/api/stats')).json();
|
||||
IAP.$('stMembers').textContent = (s.onchainMembers || 0).toLocaleString();
|
||||
IAP.$('stPurchases').textContent = (s.purchases || 0).toLocaleString();
|
||||
IAP.$('stPaid').textContent = IAP.fmtPol(s.paidInWei || '0');
|
||||
IAP.$('stPayouts').textContent = (s.payouts || 0).toLocaleString();
|
||||
} catch (e) {}
|
||||
}
|
||||
async function loadTicker() {
|
||||
try {
|
||||
const { events } = await (await fetch('/api/feed?n=30')).json();
|
||||
if (!events.length) return;
|
||||
const inner = IAP.$('tickerInner');
|
||||
inner.innerHTML = events.map(ev => '<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: 'Activate with the $20 starter package and switch on payouts from your wallet. From then on your direct referrals each pay you 50 percent of every package they ever buy, in POL, straight to your wallet. Until you activate, you earn ad credits, not POL.' },
|
||||
2: { pct: '20%', desc: 'Bring 2 buyers of $20 or more and level 2 unlocks: 20 percent of every package your referrals’ referrals buy, on every purchase, forever.' },
|
||||
3: { pct: '10%', desc: 'At 5 qualifying buyers, level 3 opens the third generation: 10 percent of everything they buy. Eight positions deep in this picture, and it keeps growing.' }
|
||||
};
|
||||
const viz = document.getElementById('genViz');
|
||||
if (viz) {
|
||||
const chips = [...document.querySelectorAll('.chips [data-lvl]')];
|
||||
const setLvl = n => {
|
||||
viz.dataset.lvl = n;
|
||||
document.getElementById('vizPct').textContent = LVL[n].pct;
|
||||
document.getElementById('lvlDesc').textContent = LVL[n].desc;
|
||||
chips.forEach(ch => ch.classList.toggle('on', ch.dataset.lvl === String(n)));
|
||||
};
|
||||
let cur = 1;
|
||||
let auto = null;
|
||||
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
auto = setInterval(() => { cur = cur % 3 + 1; setLvl(cur); }, 4200);
|
||||
}
|
||||
chips.forEach(ch => ch.addEventListener('click', () => {
|
||||
if (auto) { clearInterval(auto); auto = null; } // a click takes the wheel
|
||||
cur = Number(ch.dataset.lvl);
|
||||
setLvl(cur);
|
||||
}));
|
||||
}
|
||||
|
||||
// what-if calculator: pure arithmetic on the locked constants
|
||||
const dc = document.getElementById('dcDirects');
|
||||
if (dc) {
|
||||
const $id = x => document.getElementById(x);
|
||||
const usd = n => '$' + n.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||
const recalc = () => {
|
||||
const d = Number($id('dcDirects').value);
|
||||
const p = Number($id('dcPkg').value);
|
||||
const r = Number($id('dcSpread').value);
|
||||
$id('dcDirectsV').textContent = d;
|
||||
$id('dcSpreadV').textContent = r;
|
||||
const qualifies = p >= 20; // sub-$20 packages never count toward qualification
|
||||
const l2open = qualifies && d >= 2;
|
||||
const l3open = qualifies && d >= 5;
|
||||
const g2 = d * r, g3 = g2 * r;
|
||||
const e1 = d * p * 0.5;
|
||||
const e2 = l2open ? g2 * p * 0.2 : 0;
|
||||
const e3 = l3open ? g3 * p * 0.1 : 0;
|
||||
$id('dcN1').textContent = d; $id('dcN2').textContent = g2; $id('dcN3').textContent = g3;
|
||||
$id('dcE1').textContent = usd(e1);
|
||||
$id('dcE2').textContent = l2open ? usd(e2) : 'passes up';
|
||||
$id('dcE3').textContent = l3open ? usd(e3) : 'passes up';
|
||||
$id('dcTotal').textContent = usd(e1 + e2 + e3);
|
||||
const setB = (el, open, need) => { el.textContent = open ? 'open' : 'locked: ' + need; el.className = 'badge' + (open ? '' : ' amber'); };
|
||||
setB($id('dcB1'), true, '');
|
||||
setB($id('dcB2'), l2open, qualifies ? (2 - d) + ' more buyer(s)' : 'needs $20+ buyers');
|
||||
setB($id('dcB3'), l3open, qualifies ? (5 - d) + ' more buyer(s)' : 'needs $20+ buyers');
|
||||
$id('dcQualNote').textContent = qualifies
|
||||
? 'Buyers of $20 or more count toward your qualification. 2 unlock level 2, 5 unlock level 3.'
|
||||
: 'Heads up: $5 packages pay your level 1 but do not qualify buyers, so levels 2 and 3 stay locked in this scenario.';
|
||||
};
|
||||
['dcDirects', 'dcPkg', 'dcSpread'].forEach(x => $id(x).addEventListener('input', recalc));
|
||||
recalc();
|
||||
}
|
||||
|
||||
IAP.adSlot('banner', 'adSlotHome');
|
||||
loadLadder();
|
||||
loadStats();
|
||||
loadTicker();
|
||||
setInterval(loadStats, 60000);
|
||||
})();
|
||||
@@ -0,0 +1,112 @@
|
||||
// Invite / lead-capture page: /join/<token>[?v=<angle>]
|
||||
// Email first (code sign-in creates the account), wallet later inside the
|
||||
// member area. The angle only changes the hook copy; the sponsor cookie was
|
||||
// set by the server when this page was served.
|
||||
(function () {
|
||||
const $ = id => document.getElementById(id);
|
||||
const LEG = (brand, seg) => seg === 'adv'
|
||||
? { eyebrow: 'For former ' + brand + ' advertisers', h: 'Your next ad budget <em>pays you back.</em>',
|
||||
lead: brand + ' is closed. The people who bought ads there are exactly who LinkSpin was built for: real ad packages from $5, seven formats, and every package in your line paid out by a verified contract on Polygon in the same transaction.',
|
||||
points: ['Welcome-back credits land the moment your account exists: enough to run a real banner or text campaign today, on us.', 'Seven formats: banners, text ads, login ads, solo ads to member inboxes, video, featured links and verified visits. Views are timed on the server, so a real person saw your ad.', 'When anyone in your line buys ads, the contract pays you in that same transaction. Public on Polygonscan, nothing held, nothing to withdraw.'],
|
||||
cta: 'Claim your welcome-back credits', sub: 'Free account by email. No password, no wallet today. Use the email you had on ' + brand + ': the credits are tied to it.', video: false }
|
||||
: { eyebrow: 'For former ' + brand + ' members', h: 'Same daily habit. <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 = {
|
||||
'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 LinkSpin that is not a metaphor. A smart contract on Polygon splits every ad package the moment it sells.',
|
||||
points: ['A verified contract splits every package in the same transaction it sells: 50 percent to the sponsor, 20 and 10 up the line, 20 to the platform.', 'It lands in your own wallet in seconds. There is no balance to withdraw because nothing is ever held.', 'Every payment is public on Polygonscan, so you can check the claim before you spend a dollar.'],
|
||||
cta: 'See a payout land in seconds', sub: 'Free account by email. No password, no wallet today. Your invite link is live the moment you are in.' },
|
||||
adspend: { eyebrow: 'For marketers who buy traffic', h: 'You were buying <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.' }
|
||||
};
|
||||
// ?name=First personalises the page (site-owner links, Marty 2026-09-12): letters, spaces, hyphens,
|
||||
// apostrophes only, 30 chars. On the company placement links it says the top spot is reserved.
|
||||
const who = String(new URLSearchParams(location.search).get('name') || '').replace(/[^A-Za-z\u00C0-\u024F' -]/g, '').trim().slice(0, 30);
|
||||
if (who) {
|
||||
const top = /^\/join\/(company|top)$/i.test(location.pathname);
|
||||
const h = $('jnHello');
|
||||
if (h) { h.textContent = who + (top ? ', your spot directly under the company at the top is reserved for you. Activate with the $20 package to claim it.' : ', this invitation is for you.'); h.hidden = false; }
|
||||
}
|
||||
const v = new URLSearchParams(location.search).get('v') || (document.cookie.match(/(?:^|; )iap\.angle=([^;]+)/) || [])[1] || '';
|
||||
const a = v && ANGLES[v];
|
||||
if (a) {
|
||||
document.body.classList.add('squeeze'); // server sets it too; this covers cached HTML
|
||||
$('jnEyebrow').textContent = '· ' + a.eyebrow; $('jnHead').innerHTML = a.h; $('jnLead').textContent = a.lead;
|
||||
if (a.cta) { $('jnCapH').textContent = a.cta; $('jnCapSub').textContent = a.sub || ''; }
|
||||
// the matching hook video + this angle's three points replace the worked example
|
||||
const VID = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/';
|
||||
const vid = $('jnVideo');
|
||||
if (a.video === false) vid.hidden = true; else { vid.src = VID + v + '.mp4'; vid.poster = VID + v + '.jpg'; }
|
||||
$('jnPoints').innerHTML = (a.points || []).map(t => '<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();
|
||||
if (r.error) throw new Error(r.error);
|
||||
return r;
|
||||
}
|
||||
const err = m => { const e = $('jnErr'); e.textContent = m || ''; e.hidden = !m; };
|
||||
function busy(btn, fn) {
|
||||
return async () => { if (btn.disabled) return; btn.disabled = true; err(''); try { await fn(); } catch (e) { err(e.message || 'Something went wrong.'); } finally { btn.disabled = false; } };
|
||||
}
|
||||
const codeOpts = () => ({ honeypot: $('jnWebsite'), host: $('jnCheck') });
|
||||
const send = busy($('jnSend'), async () => {
|
||||
const r = await IAP.requestCode($('jnEmail').value, codeOpts());
|
||||
$('jnCodeRow').hidden = false; $('jnVerify').hidden = false; $('jnSend').hidden = true; $('jnResend').hidden = false;
|
||||
if (r.devCode) $('jnCode').value = r.devCode;
|
||||
// the last thing they see before the account is created: who they are joining under
|
||||
if (sponsorName) { $('jnUnder').textContent = 'Joining under ' + sponsorName + '. Not who invited you? Open their invite link first, then come back for the code.'; $('jnUnder').hidden = false; }
|
||||
IAP.status(r.sent ? 'Code sent. Check your inbox (and spam, the first time).' : 'Dev mode: code filled in.', 'ok');
|
||||
$('jnCode').focus();
|
||||
});
|
||||
$('jnSend').addEventListener('click', send);
|
||||
$('jnResend').addEventListener('click', busy($('jnResend'), async () => {
|
||||
const r = await IAP.requestCode($('jnEmail').value, codeOpts());
|
||||
if (r.devCode) $('jnCode').value = r.devCode;
|
||||
IAP.status('Fresh code sent.', 'ok');
|
||||
}));
|
||||
$('jnVerify').addEventListener('click', busy($('jnVerify'), async () => {
|
||||
await api('/api/auth/email/verify', { email: $('jnEmail').value, code: $('jnCode').value, newsletter: !!$('jnNews').checked, followups: !!$('jnNews').checked });
|
||||
IAP.status('You are in. Taking you to your dashboard…', 'ok');
|
||||
location.href = '/my?welcome=1'; // dashboard runs the username step + welcome tour on arrival
|
||||
}));
|
||||
$('jnEmail').addEventListener('keydown', e => { if (e.key === 'Enter') ($('jnVerify').hidden ? $('jnSend') : $('jnVerify')).click(); });
|
||||
$('jnCode').addEventListener('keydown', e => { if (e.key === 'Enter') $('jnVerify').click(); });
|
||||
|
||||
// sponsor line (the link opened most recently sets the sponsor; it locks at account creation)
|
||||
let sponsorName = '';
|
||||
const linkTok = (location.pathname.match(/^\/join\/([^/?#]+)/) || [])[1] || '';
|
||||
fetch('/api/sponsor' + (linkTok ? '?ref=' + encodeURIComponent(linkTok) : '')).then(r => r.json()).then(sp => {
|
||||
if (sp && sp.invited && sp.name) {
|
||||
sponsorName = sp.name;
|
||||
$('jnSponName').textContent = sp.name + (sp.own ? ' (this is your own invite page; visitors see your name here)' : '');
|
||||
if (sp.avatarUrl) { $('jnSponImg').src = sp.avatarUrl; $('jnSponImg').hidden = false; }
|
||||
if (sp.cobrand && sp.bio && $('jnSponBio')) { $('jnSponBio').textContent = sp.bio; $('jnSponBio').hidden = false; $('jnSpon').classList.add('cobrand'); }
|
||||
$('jnSpon').hidden = false;
|
||||
}
|
||||
}).catch(() => {});
|
||||
// package ladder (dollar constants + live POL quote)
|
||||
const NAMES = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' };
|
||||
fetch('/api/catalog').then(r => r.json()).then(c => {
|
||||
const wrap = $('jnLadder'); if (!wrap || !c.products) return;
|
||||
wrap.innerHTML = c.products.filter(p => p.active !== false).map(p =>
|
||||
'<div class="jn-pk"><div class="n">' + (NAMES[p.id] || 'Package ' + p.id) + '</div><div class="p">' + IAP.fmtUsd(p.priceCents) + '</div><div class="c">' + Number(p.creditAmount).toLocaleString() + ' credits</div>'
|
||||
+ (p.costWei ? '<div class="small muted">' + IAP.fmtPol(p.costWei) + ' POL now</div>' : '') + '</div>').join('');
|
||||
}).catch(() => {});
|
||||
fetch('/api/stats').then(r => r.json()).then(s => {
|
||||
if (s && s.onchainMembers) $('jnStats').textContent = s.onchainMembers.toLocaleString() + (s.onchainMembers === 1 ? ' member' : ' members') + ' on-chain so far.';
|
||||
}).catch(() => {});
|
||||
})();
|
||||
@@ -0,0 +1,114 @@
|
||||
// Founding week checklist: members only; every item reads from the live account
|
||||
// (IAP.launchChecks in common.js is shared with the dashboard's "launch ready" mark).
|
||||
(async function () {
|
||||
const $ = id => document.getElementById(id);
|
||||
try { await IAP.renderNav('training'); } catch (e) {}
|
||||
let me = null, cfg = {};
|
||||
try { me = await (await fetch('/api/me')).json(); } catch (e) {}
|
||||
// buyerCount and the referral list live on the dashboard endpoint; merge it in
|
||||
if (me && me.signedIn) { try { const d = await (await fetch('/api/my/dashboard')).json(); if (d && !d.error) me = Object.assign({}, me, d, { signedIn: true, email: me.email }); } catch (e) {} }
|
||||
try { cfg = await IAP.getConfig(); } catch (e) {}
|
||||
const signedIn = !!(me && me.signedIn && me.email);
|
||||
$('gate').style.display = signedIn ? 'none' : 'block';
|
||||
$('body').style.display = signedIn ? 'block' : 'none';
|
||||
if (!signedIn) return;
|
||||
const pb = $('printBtn'); if (pb) pb.addEventListener('click', () => window.print());
|
||||
const tok = me.username || me.refCode || me.memberId;
|
||||
if (tok) document.querySelectorAll('[data-link]').forEach(el => { el.textContent = location.origin + '/join/' + tok; });
|
||||
// launch week swipes: four promoter emails, the member's link + FOUNDER code filled in (Marty, 2026-09-15)
|
||||
const LINK = location.origin + '/join/' + (tok || '') + '?promo=FOUNDER';
|
||||
const SW = [
|
||||
{ when: 'Day 1 · six days out', subject: 'Something opens Monday. I am bringing a few people in early.', body: `Quick one, because I want you positioned before this goes public.
|
||||
|
||||
I have been inside a new advertising platform for a week. It pays sponsors in the same transaction a package is bought, wallet to wallet, on the Polygon ledger where anyone can check it. No back office. No payday. I have watched the payouts land.
|
||||
|
||||
It opens to everyone Monday, September 21 at 9 AM Central. Founding members are in now, and the people they bring in before then are the ones the launch traffic lands under.
|
||||
|
||||
Joining is free with an email. This week the code FOUNDER gives you 500 ad credits to run a campaign on day one:
|
||||
|
||||
{link}
|
||||
|
||||
No income is promised. It is an ad platform with a referral program. But the payment mechanics are real and public, and that is why I am in.
|
||||
|
||||
Look before Monday. I will walk you through the first three steps.`},
|
||||
{ when: 'Day 2 · five days out', subject: 'Five hundred free credits, and what you can do with them', body: `Yesterday I sent you a link. Here is what is on the other side of it.
|
||||
|
||||
You join free with your email. Every day you view a short set of ads and earn credits. Credits run your own ads: banners, text ads, video ads, solo mailings, verified visits, across the whole network.
|
||||
|
||||
This week the FOUNDER code adds 500 credits on top the moment you join, enough to launch a real campaign pointed at whatever you are promoting. Watch it deliver before Monday, when everyone else arrives.
|
||||
|
||||
The other half is simple. When someone you invite buys an ad package, the contract pays you 50 percent instantly. Level two gets 20 percent, level three 10 percent. Same transaction, on the public ledger.
|
||||
|
||||
{link}
|
||||
|
||||
Use the code before it expires Monday morning. Then tell me you are in and I will show you the checklist.`},
|
||||
{ when: 'Day 4 · the weekend', subject: 'The two people you bring in this weekend', body: `The doors open Monday morning and I am spending the weekend getting my first people set up. I want you to be one of them.
|
||||
|
||||
Here is why the weekend matters. Everyone who joins now is a founding member. The traffic that arrives Monday lands under whoever is already in and set up. Two people under you before Monday, and your line is built when the wave hits.
|
||||
|
||||
Setup takes ten minutes: username, link a wallet, switch on payouts. Then share your link. The site gives you posts, swipes, banners and a leaderboard with weekly credit prizes for the top sponsors.
|
||||
|
||||
Join with the FOUNDER code and the 500 credits are yours:
|
||||
|
||||
{link}
|
||||
|
||||
Message me when you are in and I will go through the checklist with you before Monday.`},
|
||||
{ when: 'Day 6 · Sunday evening', subject: 'Doors open tomorrow at 9 AM Central', body: `Last note before the launch.
|
||||
|
||||
LinkSpin opens to the public tomorrow, Monday, at 9 AM Central. After that the FOUNDER code is gone. Tonight it still gives you 500 free ad credits.
|
||||
|
||||
If you join tonight you are in as a founding member with a campaign already funded and your link ready when the first wave arrives. If you wait, you join the wave.
|
||||
|
||||
Free to join, real advertising, and every payment lands in your own wallet the second it happens, on a ledger anyone can read. No income promises. Just mechanics you can verify.
|
||||
|
||||
{link}
|
||||
|
||||
I will be online from 8 AM tomorrow walking people through the first three steps. Get in tonight and you are ahead of them.`}
|
||||
];
|
||||
// short posts and a DM for the same days, one per day, link + code included
|
||||
const POSTS = [
|
||||
{ when: 'Day 1 · six days out', text: `Something I have been inside for a week opens to the public Monday, September 21 at 9 AM Central. An ad platform that pays sponsors in the same transaction a package is bought, on the Polygon ledger where anyone can check it. Free to join. Code FOUNDER gives you 500 ad credits before then. No income promises, just mechanics you can verify. {link}` },
|
||||
{ when: 'Day 2 · five days out', text: `500 free ad credits for joining before Monday. View a short daily set of ads, earn more, run banners, text ads, video ads and solo mailings across the network. When someone you invite buys a package, the contract pays you 50 percent instantly, wallet to wallet. Code FOUNDER. {link}` },
|
||||
{ when: 'Day 4 · the weekend', text: `The doors open Monday morning. Everyone joining this weekend is a founding member, and the launch traffic lands under whoever is already set up. Ten minutes to set up, 500 credits with code FOUNDER, and a leaderboard that pays weekly credit prizes to the top sponsors. {link}` },
|
||||
{ when: 'Day 6 · Sunday evening', text: `Doors open tomorrow, 9 AM Central. Tonight the FOUNDER code still gives you 500 free ad credits; tomorrow it is gone. Free to join, real advertising, every payment on a public ledger. Get in tonight and you are ahead of the wave. {link}` },
|
||||
{ when: 'Text or DM · any day', text: `Hey, I am in something that opens publicly Monday and I am bringing a few people in early. Free to join, real advertising, every payment lands in your own wallet the second it happens. Use code FOUNDER for 500 free credits. Ten minutes to set up and I will walk you through it: {link}` }
|
||||
];
|
||||
const pl = $('postList');
|
||||
if (pl) {
|
||||
pl.innerHTML = POSTS.map((s, i) => '<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,31 @@
|
||||
// Live ledger: recent history + SSE stream of new chain events.
|
||||
(async function () {
|
||||
await IAP.renderNav('ledger');
|
||||
const c = await IAP.getConfig();
|
||||
IAP.$('contractLink').href = c.explorer ? c.explorer + '/address/' + c.contract : '/contract';
|
||||
const feed = IAP.$('feed');
|
||||
|
||||
const { events } = await (await fetch('/api/feed?n=150')).json();
|
||||
feed.innerHTML = '';
|
||||
if (!events.length) feed.innerHTML = '<div class="row muted">No activity yet. The first purchase will appear here the moment it lands.</div>';
|
||||
for (const ev of events) feed.appendChild(IAP.feedRow(ev, c));
|
||||
|
||||
try {
|
||||
const stats = await (await fetch('/api/stats')).json();
|
||||
IAP.$('statLine').textContent = stats.onchainMembers + ' on-chain member(s)';
|
||||
} catch (e) {}
|
||||
|
||||
IAP.adSlot('banner', 'adSlotBanner');
|
||||
IAP.adSlot('text', 'adSlotText');
|
||||
|
||||
const es = new EventSource('/api/feed/live');
|
||||
es.onopen = () => { const b = IAP.$('liveBadge'); b.textContent = '● live'; };
|
||||
es.onerror = () => { const b = IAP.$('liveBadge'); b.textContent = 'reconnecting…'; };
|
||||
es.onmessage = m => {
|
||||
try {
|
||||
const ev = JSON.parse(m.data);
|
||||
feed.prepend(IAP.feedRow(ev, c));
|
||||
while (feed.children.length > 200) feed.removeChild(feed.lastChild);
|
||||
} catch (e) {}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,2 @@
|
||||
// legal/static content pages: render the shared nav + footer
|
||||
(async function () { try { await IAP.renderNav(''); } catch (e) {} })();
|
||||
@@ -0,0 +1,21 @@
|
||||
// Site-owner partner page: ?name=First greets the owner, ?promo=CODE rides along into the
|
||||
// company placement link and the example builder link. Display only; placement and credits
|
||||
// come from the join route and the promo code.
|
||||
(function () {
|
||||
const q = new URLSearchParams(location.search);
|
||||
const name = String(q.get('name') || '').replace(/[^A-Za-zÀ-ɏ' -]/g, '').trim().slice(0, 30);
|
||||
const promo = String(q.get('promo') || '').toUpperCase().replace(/[^A-Z0-9_-]/g, '').slice(0, 24);
|
||||
const ref = String(q.get('ref') || '').toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 20); // a member's own partner kit (Nexus): the deal lands under them
|
||||
const hello = document.getElementById('pkHello');
|
||||
if (hello && name) { hello.textContent = name + ', this page is for you. Your spot directly under the company at the top is reserved; activate with the $20 package to claim it.'; hello.style.display = 'block'; }
|
||||
const cta = document.getElementById('pkCta');
|
||||
if (cta) {
|
||||
const p = new URLSearchParams(); if (name) p.set('name', name); if (promo) p.set('promo', promo);
|
||||
cta.href = (ref ? '/join/' + ref : '/join/company') + (p.toString() ? '?' + p.toString() : '');
|
||||
if (name) cta.textContent = 'Claim my spot, ' + name;
|
||||
}
|
||||
const ex = document.getElementById('pkExample');
|
||||
if (ex && promo) ex.textContent = 'https://linkspin-test.saasy.top/join/yourname?promo=' + promo;
|
||||
const sub = document.getElementById('pkCtaSub');
|
||||
if (sub && promo) sub.textContent = 'Free account by email. No password, no wallet today. The link below places you directly under the company and applies your code ' + promo + ' as well.';
|
||||
})();
|
||||
@@ -0,0 +1,14 @@
|
||||
// Team-building plays page: members only, and the scripts carry the member's own link.
|
||||
(async function () {
|
||||
try { await IAP.renderNav('training'); } catch (e) {}
|
||||
let me = null;
|
||||
try { me = await (await fetch('/api/me')).json(); } catch (e) {}
|
||||
const signedIn = !!(me && me.signedIn && me.email);
|
||||
document.getElementById('gate').style.display = signedIn ? 'none' : 'block';
|
||||
document.getElementById('body').style.display = signedIn ? 'block' : 'none';
|
||||
if (!signedIn) return;
|
||||
try { IAP.adSlot('banner', 'adSlotPlays'); } catch (e) {}
|
||||
const pb = document.getElementById('printBtn'); if (pb) pb.addEventListener('click', () => window.print());
|
||||
const tok = me.username || me.refCode || me.memberId;
|
||||
if (tok) document.querySelectorAll('[data-link]').forEach(el => { el.textContent = location.origin + '/join/' + tok; });
|
||||
})();
|
||||
@@ -0,0 +1,217 @@
|
||||
// Promo tools content + rendering for the member area (Promo tools pane).
|
||||
// Everything is personalized from the member's join link. Angle links carry
|
||||
// ?v=<angle> so they keep working when the matched squeeze pages ship.
|
||||
// Copy rules: no income promises, package prices in dollars are fine, never a
|
||||
// POL figure, no em dashes.
|
||||
window.IAPPromo = (function () {
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const status = (m, c) => (window.IAP && IAP.status) ? IAP.status(m, c) : console.log(m);
|
||||
const angleLink = (link, angle) => angle ? link + '?v=' + angle : link;
|
||||
|
||||
// ── social posts ──
|
||||
const POSTS = [
|
||||
{ net: 'X', label: 'X · instant payout', text: 'No withdraw button. A smart contract on Polygon splits every ad package the second it sells: 50% to the sponsor, 20% to level 2, 10% to level 3. Lands in your own wallet. Watch the ledger move: {{LINK:instant}}' },
|
||||
{ net: 'X', label: 'X · advertiser', text: 'Marketers: you were buying traffic anyway. Here the ad spend in your line pays you, in the same transaction, on a public ledger. Packages from $5. Join free: {{LINK:adspend}}' },
|
||||
{ net: 'X', label: 'X · free join', text: 'Join free. View a few ads, earn credits, run your first campaign for zero dollars. Seven ad formats, real dwell-timed attention, every payout public on Polygon. {{LINK:free}}' },
|
||||
{ net: 'Facebook', label: 'Facebook · story post', text: 'I got tired of "your commission is pending." So I joined a platform where there is no pending. A smart contract on the Polygon blockchain splits every ad package the moment it sells: half to the sponsor, then levels two and three, then the platform. It lands in your own wallet in seconds. No approval queue, no withdrawal button, no 15th of the month.\n\nYou are buying real advertising: banners, text ads, login ads, solo ads to member inboxes, video ads, featured links and verified visits. Members earn credits for their attention, so the ads actually get seen.\n\nJoin free, look around, and check the public ledger before you spend a dollar: {{LINK:instant}}\n\nNo income promises. It is advertising, not investing, and crypto carries risk.' },
|
||||
{ net: 'Facebook', label: 'Facebook · skeptic angle', text: 'Every online program I ever joined asked me to trust a back office. This one does not. Every payout is a public transaction on Polygon you can look up yourself, with the wallet addresses and amounts right there. The split is written in a verified smart contract nobody can quietly edit.\n\nFree to join with just an email. The wallet comes out only if you buy a package or switch on payouts. Packages run $5 to $250, and every one of them is ad delivery you can watch running.\n\nTake the tour: {{LINK:ledger}}' },
|
||||
{ net: 'LinkedIn', label: 'LinkedIn · professional', text: 'An experiment in transparent affiliate payouts: LinkSpin sells advertising packages ($5 to $250) whose sponsor commissions are settled by a verified smart contract on Polygon in the same transaction as the purchase. 50 / 20 / 10 across three levels, 20 percent to the platform, every payment public on the chain.\n\nWhat I find interesting is not the commission. It is that "pending payout" stops being a concept. If you buy traffic for a living and want to see how same-transaction settlement works in practice, the tour is free: {{LINK:adspend}}' },
|
||||
{ net: 'Telegram', label: 'Telegram or WhatsApp group', text: 'Quick one for the group. Ad platform on Polygon where every package splits to sponsor wallets the second it sells. No withdrawals, no pending. Free to join by email, packages from $5, seven ad formats, and you earn credits for viewing. Tour here: {{LINK:instant}}' },
|
||||
{ net: 'Telegram', label: 'Telegram or WhatsApp group · free angle', text: 'If you want to test an ad network without spending anything: join free, view a handful of ads, earn credits, run your first campaign on the house. Payouts are on the public Polygon ledger. {{LINK:free}}' }
|
||||
];
|
||||
|
||||
// ── text a friend (SMS-sized) ──
|
||||
const TEXTS = [
|
||||
{ angle: '', title: '"Thought of you"', text: 'Hey, found an ad platform where the commission lands in your wallet the second someone buys. No pending, no withdraw button. Free to look: {{LINK}}' },
|
||||
{ angle: 'instant', title: '"Before the page reloads"', text: 'Random thought. What if your commission showed up before the thank-you page finished loading? That is literally how this works: {{LINK:instant}}' },
|
||||
{ angle: 'adspend', title: '"You buy ads anyway"', text: 'You already buy traffic. This one pays your line every time someone in it buys ads, same transaction, on Polygon. Two-minute look: {{LINK:adspend}}' },
|
||||
{ angle: 'free', title: '"Costs nothing to try"', text: 'Try this without spending a dollar: join free, view a few ads, earn credits, run your first campaign. {{LINK:free}}' },
|
||||
{ angle: 'ledger', title: '"No back office"', text: 'Remember waiting on payouts that never came? This one has no back office. Every payment is public on the blockchain. Check it yourself: {{LINK:ledger}}' }
|
||||
];
|
||||
|
||||
// ── email swipes ──
|
||||
const SWIPES = [
|
||||
{ tier: 'Short', subject: 'Paid the second it sells', body: 'Quick one.\n\nI joined an ad platform where every package splits to sponsor wallets in the same transaction as the sale. No pending payouts, no withdraw button. It is on the Polygon blockchain, and every payment is public.\n\nJoin free, look at the ledger, decide later: {{LINK:instant}}\n\nNo income promises. Advertising, not investing.' },
|
||||
{ tier: 'Standard', subject: 'Your wallet gets paid instantly', body: 'You know the usual drill. Someone buys on your link, you wait for a payout. Maybe days. Maybe an approval hold. Maybe a "your account is under review."\n\nLinkSpin does not work like that.\n\nA smart contract on the Polygon blockchain handles every purchase the second it happens. 50% to the sponsor. 20% to the next level. 10% to the one after that. 20% to the platform. Each split lands directly in your own wallet. No withdrawal button. No "request payout." No approval queue.\n\nThe money just shows up.\n\nYou can watch every transaction on the public ledger. Real time. Anyone can verify it.\n\nFree to join. Packages from $5 to $250. No income promises. It is advertising, not investing.\n\n{{LINK}}' },
|
||||
{ tier: 'Long', subject: 'The ad network with no pending payouts', body: 'Let me tell you what you are actually looking at, because "crypto ad platform" can mean anything.\n\nLinkSpin sells advertising. Five packages, $5 to $250. A package mints ad credits, and one credit is one cent of delivery across seven formats: display banners, text ads, full-screen login ads, solo ads delivered into member inboxes, video ads, featured links, and verified visits. Members earn credits for their attention, with a timer that pauses when they look away, so your ad is seen by a person, not a script.\n\nNow the part that made me join.\n\nWhen anyone in your line buys a package, a verified smart contract on Polygon splits the payment in that same transaction: 50 percent to their direct sponsor, 20 percent to level two, 10 percent to level three, 20 percent to the platform. It lands in real wallets in seconds. There is no balance to withdraw because nothing is ever held.\n\nEvery one of those payments is public. Open the ledger, click a transaction, read it on Polygonscan.\n\nJoining is free and only needs an email. Your wallet comes out when you buy a package or switch on payouts. If you never spend a dollar, you can still view ads, earn credits, and run a small campaign on those.\n\nHave a look: {{LINK:instant}}\n\nOne honest line: nobody is promising you an income. Results depend on your effort, and crypto carries risk of loss.' },
|
||||
{ tier: 'Follow-up', subject: 'Did you see the ledger?', body: 'Following up on the ad platform I sent over.\n\nIf you only look at one thing, look at the live ledger. Every purchase and every payout, with real wallet addresses, in the order they happened. That is the whole pitch: nothing to take on faith.\n\n{{LINK:ledger}}\n\nIf it is not for you, no worries at all.' }
|
||||
];
|
||||
|
||||
// ── objection bank (truth + say this) ──
|
||||
const OBJECTIONS = [
|
||||
{ q: 'Which crypto do I get paid in?', truth: 'One coin, one network: POL, the native coin of Polygon. Packages are dollar-priced and settled in POL at the live Chainlink rate; every payout is sent as POL to the recipient\'s own Polygon wallet in the same transaction. No tokens, no other chains, no stablecoins.', say: 'You get paid in POL, which is Polygon\'s own coin, straight into your wallet the moment a package in your line sells. Any Polygon wallet works, MetaMask, SafePal, Phantom, and if you have never held crypto you can buy POL with a card inside the member area. {{LINK}}' },
|
||||
{ q: 'Is this a pyramid scheme?', truth: 'You are buying advertising that actually runs: a credit is one cent of delivery across seven live formats, and members earn credits for dwell-timed attention. Sponsor payments are referral commissions written as constants in a verified contract, paid only when a real package sells. Nobody has to recruit to use the ads, and joining costs nothing.', say: 'Fair question. Here is the test I use: is there a real product that people would buy with no referral involved? Here the product is ad delivery, seven formats, one cent per credit, and you can watch your campaign serving. The sponsor split is a referral commission on those sales, fixed in a public contract. Join free and run the ads with zero referrals if you like. {{LINK:free}}' },
|
||||
{ q: 'I do not have a crypto wallet. I am not technical.', truth: 'Joining needs only an email and a 6-digit code. The wallet comes out only when someone buys a package or switches on payouts, and the site walks them through it. A card on-ramp (MoonPay) delivers POL straight to their own wallet. Email first, wallet second.', say: 'You do not need one to join. It is email only, no password even. The wallet shows up later, only if you decide to buy a package or want payouts, and the site walks you through it step by step. Start here and look around first: {{LINK}}' },
|
||||
{ q: 'What am I actually buying?', truth: 'Ad credits minted on-chain the moment a package is bought: $5 = 500, $20 = 2,000, $50 = 5,500, $100 = 12,000, $250 = 32,500. Credits spend on banners, text ads, login ads, solo ads to member inboxes, video ads, featured links and verified visits, with live stats per campaign. Only the buyer\'s campaigns can spend them.', say: 'Advertising. A package mints credits, one credit is one cent of delivery, and you spend them on seven ad formats from your own dashboard with live stats. The $5 package is 500 credits, the $250 package is 32,500. You can see the formats before you spend anything: {{LINK:adspend}}' },
|
||||
{ q: 'Who holds my money?', truth: 'Nobody. A purchase is one Polygon transaction that splits to the sponsor line and the platform wallets immediately. The site never holds balances, and the contract source is verified on Polygonscan and Sourcify. Overpayment refunds itself in the same transaction.', say: 'That is the part I like most: nobody holds it. The purchase is a single blockchain transaction that pays the sponsor line and the platform in the same moment. There is no balance sitting anywhere waiting for a withdrawal. The contract code is public and verified, and every payment is on the ledger: {{LINK:ledger}}' },
|
||||
{ q: 'What if POL drops?', truth: 'Packages are priced in dollars and settled in POL at the live Chainlink rate at the moment of purchase. Payouts arrive as POL in the recipient\'s own wallet immediately, so what they do with it is their call. This is advertising, not an investment, and POL is volatile like any crypto asset.', say: 'Packages are dollar priced, so $20 is $20 worth of POL at that moment. Payouts land in your wallet right away, and it is your money from that second. Crypto does move, so treat it as advertising you bought, not an investment. {{LINK}}' },
|
||||
{ q: 'Why would anyone buy ads here?', truth: 'The members are marketers who already buy traffic. Viewers earn credits only after a server-timed dwell, so impressions are real people. Solo ads reach member inboxes with a guaranteed delivery count, verified visits are one unique member per visit, and banner and text ads also syndicate to a partner ad network.', say: 'Because the people on it are marketers, and they get paid to actually look. Every view is dwell-timed on the server, solo ads land in real inboxes with a guaranteed count, and verified visits are one real person each. You can watch your own campaign serve: {{LINK:adspend}}' },
|
||||
{ q: 'I do not know anyone to refer.', truth: 'Referrals are optional. Members can view ads, earn credits and advertise with no line at all. When they do refer, every direct buyer pays them 50 percent from the very first package, and their line banner is shown to their next three levels during welcome tours.', say: 'You do not have to refer anyone. Join free, earn credits by viewing, run ads. If one person ever joins through you and buys a $5 package, half of it hits your wallet in that transaction. That is the whole referral side, and it is optional. {{LINK:free}}' },
|
||||
{ q: 'How much can I make?', truth: 'No income is guaranteed or implied. Sponsor commissions are 50 / 20 / 10 across three levels on packages that actually sell; level 2 needs 2 qualifying buyers ($20 or more) and level 3 needs 5. Results depend on the member\'s effort, and crypto carries risk of loss. Never quote a figure.', say: 'Nobody can tell you that, and I would not trust anyone who did. What I can tell you is exactly how it is split: 50 percent to the direct sponsor, 20 and 10 to the next two levels, paid the moment a package sells, all public. What that adds up to depends entirely on what you build. {{LINK:instant}}' }
|
||||
];
|
||||
|
||||
function fillLink(text, link) {
|
||||
return text.replace(/\{\{LINK(?::([a-z]+))?\}\}/g, (m, a) => angleLink(link, a));
|
||||
}
|
||||
function copyBtn(text, label) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'btn small sec'; b.type = 'button'; b.textContent = label || 'Copy';
|
||||
b.addEventListener('click', async () => {
|
||||
try { await navigator.clipboard.writeText(text); status('Copied. Paste it anywhere.', 'ok'); }
|
||||
catch (e) { status('Copy failed. Select the text instead.', 'bad'); }
|
||||
});
|
||||
return b;
|
||||
}
|
||||
function block(text, head, extraBtns) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'promo-block';
|
||||
if (head) { const h = document.createElement('div'); h.className = 'pb-head'; h.innerHTML = head; div.appendChild(h); }
|
||||
const pre = document.createElement('div'); pre.className = 'pb-text'; pre.textContent = text; div.appendChild(pre);
|
||||
const row = document.createElement('div'); row.className = 'pb-actions';
|
||||
row.appendChild(copyBtn(text));
|
||||
(extraBtns || []).forEach(b => row.appendChild(b));
|
||||
div.appendChild(row);
|
||||
return div;
|
||||
}
|
||||
function linkBtn(href, label, cls) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'btn small ' + (cls || 'sec'); a.href = href; a.textContent = label;
|
||||
if (!/^sms:/.test(href)) { a.target = '_blank'; a.rel = 'noopener'; }
|
||||
return a;
|
||||
}
|
||||
|
||||
// hook videos: one per angle; the matched link continues the same hook on the join page
|
||||
const VID_BASE = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/';
|
||||
const VIDEOS = [
|
||||
{ angle: 'instant', title: 'Paid before the page reloads', hook: 'What if your commission landed before the thank-you page finished loading?', caption: 'What if your commission landed before the thank-you page finished loading? On LinkSpin a smart contract splits every ad package the second it sells, straight to wallets, on a public ledger. Free to join by email: {{LINK:instant}}' },
|
||||
{ angle: 'adspend', title: 'You were buying traffic anyway', hook: 'Every ad dollar you have ever spent went one direction. Out.', caption: 'Every ad dollar you have ever spent went one direction. Out. Here the ad spend in your line pays you back in the same transaction. Seven formats, packages from $5, free to join: {{LINK:adspend}}' },
|
||||
{ angle: 'free', title: 'Watch first, spend never', hook: 'You can run your first ad campaign here for exactly zero dollars.', caption: 'Run your first ad campaign for exactly zero dollars. Join free by email, earn credits by viewing ads, launch a real campaign, and watch real payouts land on a public ledger before you spend a cent: {{LINK:free}}' },
|
||||
{ angle: 'ledger', title: 'No back office. No payday.', hook: 'Your last affiliate program paid you on the fifteenth. If it paid you.', caption: 'Your last affiliate program paid you on the fifteenth. If it paid you. Here the payroll is the blockchain: every payout is a public transaction, nothing is held, nobody can change the split. Open the ledger, then join free: {{LINK:ledger}}' },
|
||||
{ angle: 'two', title: 'Two buyers open level two', hook: 'Two buyers. Then five. That is the whole ladder.', caption: 'Two buyers. Then five. That is the whole ladder. Level 1 pays from your first buyer, two qualifying buyers open level 2, five open level 3, all in the same transaction, straight to your wallet. Free to join: {{LINK:two}}' }
|
||||
];
|
||||
// the six front doors: the plain invite link plus one page per hook angle
|
||||
const ANGLES = [
|
||||
{ angle: '', name: 'General', hook: 'The whole picture', use: 'What LinkSpin is, the payment split, join free. Use it when you have not said anything specific yet.' },
|
||||
{ angle: 'instant', name: 'Instant', hook: 'Paid before the page reloads', use: 'For anyone burned by pending commissions. The page opens on the on-chain payout that lands in seconds.' },
|
||||
{ angle: 'adspend', name: 'Ad spend', hook: 'You buy ads anyway', use: 'For marketers who already pay for traffic. Frames it as advertising that also pays your line when they buy.' },
|
||||
{ angle: 'free', name: 'Free', hook: 'Costs nothing to try', use: 'For skeptics and beginners. Join free, earn credits by viewing, run a first campaign without spending.' },
|
||||
{ angle: 'ledger', name: 'Ledger', hook: 'No back office, no payday', use: 'For people who have waited on a payout. Every payment is public on Polygonscan and nothing is ever held.' },
|
||||
{ angle: 'two', name: 'Two', hook: 'Two buyers open level two', use: 'For team builders. The 2-then-5 qualification ladder and how a line stacks under you.' }
|
||||
];
|
||||
function fill(link, me) {
|
||||
const token = (me && (me.username || me.refCode || me.memberId)) || '';
|
||||
// invite strip
|
||||
if ($('promoLink')) $('promoLink').textContent = link;
|
||||
if ($('promoLinkCopy')) $('promoLinkCopy').onclick = async () => {
|
||||
try { await navigator.clipboard.writeText(link); status('Link copied.', 'ok'); } catch (e) { status('Copy failed.', 'bad'); }
|
||||
};
|
||||
// angle links: one row per front door, copy + share
|
||||
const al = $('promoAngles');
|
||||
if (al && al.dataset.filled !== link) {
|
||||
al.dataset.filled = link; al.innerHTML = '';
|
||||
for (const a of ANGLES) {
|
||||
const url = angleLink(link, a.angle);
|
||||
const hasVideo = VIDEOS.some(v => v.angle === a.angle);
|
||||
// one collapsed band per angle, like the banner kit: open the one you want
|
||||
const row = document.createElement('details');
|
||||
row.className = 'angle-row pb-acc';
|
||||
row.innerHTML = '<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: 'LinkSpin', text: a.hook, url }); } catch (e) { if (!(e && e.name === 'AbortError')) status('Could not open the share sheet.', 'bad'); } };
|
||||
al.appendChild(row);
|
||||
}
|
||||
}
|
||||
// posts
|
||||
const posts = $('promoPosts');
|
||||
if (posts && posts.dataset.filled !== link) {
|
||||
posts.dataset.filled = link; posts.innerHTML = '';
|
||||
for (const p of POSTS) {
|
||||
const text = fillLink(p.text, link);
|
||||
const extras = [];
|
||||
if (p.net === 'X') extras.push(linkBtn('https://twitter.com/intent/tweet?text=' + encodeURIComponent(text), 'Post on X'));
|
||||
if (p.net === 'Facebook') extras.push(linkBtn('https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(angleLink(link, (/\{\{LINK:([a-z]+)\}\}/.exec(p.text) || [])[1] || '')), 'Share on Facebook'));
|
||||
if (p.net === 'LinkedIn') extras.push(linkBtn('https://www.linkedin.com/sharing/share-offsite/?url=' + encodeURIComponent(angleLink(link, 'adspend')), 'Share on LinkedIn'));
|
||||
if (p.net === 'Telegram') extras.push(linkBtn('https://t.me/share/url?url=' + encodeURIComponent(angleLink(link, (/\{\{LINK:([a-z]+)\}\}/.exec(p.text) || [])[1] || '')) + '&text=' + encodeURIComponent(text.replace(/\s*\{\{LINK[^}]*\}\}\s*$/, '').replace(/https?:\/\/\S+$/, '').trim()), 'Share on Telegram'));
|
||||
posts.appendChild(block(text, '<span class="pb-net">' + esc(p.label) + '</span>', extras));
|
||||
}
|
||||
}
|
||||
// text a friend
|
||||
const texts = $('promoTexts');
|
||||
if (texts && texts.dataset.filled !== link) {
|
||||
texts.dataset.filled = link; texts.innerHTML = '';
|
||||
TEXTS.forEach((t, i) => {
|
||||
const text = fillLink(t.text, link);
|
||||
const enc = encodeURIComponent(text);
|
||||
const tgUrl = angleLink(link, t.angle);
|
||||
const tgLead = text.replace(/https?:\/\/\S+/, '').trim();
|
||||
const extras = [
|
||||
linkBtn('sms:?&body=' + enc, 'Text it', ''),
|
||||
linkBtn('https://wa.me/?text=' + enc, 'WhatsApp'),
|
||||
linkBtn('https://t.me/share/url?url=' + encodeURIComponent(tgUrl) + '&text=' + encodeURIComponent(tgLead), 'Telegram')
|
||||
];
|
||||
texts.appendChild(block(text, '<span class="pb-net">Text ' + (i + 1) + (t.angle ? ' · ' + esc(t.angle) + ' angle' : ' · general') + '</span> <b>' + esc(t.title) + '</b>', extras));
|
||||
});
|
||||
}
|
||||
// swipes
|
||||
const sw = $('promoSwipeWrap');
|
||||
if (sw && sw.dataset.filled !== link) {
|
||||
sw.dataset.filled = link; sw.innerHTML = '';
|
||||
for (const s of SWIPES) {
|
||||
const text = 'Subject: ' + s.subject + '\n\n' + fillLink(s.body, link);
|
||||
sw.appendChild(block(text, '<span class="pb-net">' + esc(s.tier) + '</span> <b>' + esc(s.subject) + '</b>'));
|
||||
}
|
||||
}
|
||||
// hook videos
|
||||
const vw = $('promoVideos');
|
||||
if (vw && vw.dataset.filled !== link) {
|
||||
vw.dataset.filled = link; vw.innerHTML = '';
|
||||
for (const v of VIDEOS) {
|
||||
const mlink = angleLink(link, v.angle);
|
||||
const card = document.createElement('div'); card.className = 'pv';
|
||||
card.innerHTML = '<div class="pv-head"><span class="pb-net">' + esc(v.angle) + ' angle</span> <b>' + esc(v.title) + '</b><div class="muted small">' + esc(v.hook) + '</div></div>'
|
||||
+ '<video src="' + VID_BASE + v.angle + '.mp4" poster="' + VID_BASE + v.angle + '.jpg" preload="none" controls playsinline style="width:100%;max-width:640px;border-radius:12px;background:#000;margin:10px 0"></video>'
|
||||
+ '<p class="small"><span class="muted">Matched link:</span> <span class="mono" style="overflow-wrap:anywhere">' + esc(mlink) + '</span></p>';
|
||||
const row = document.createElement('p'); row.className = 'pb-actions';
|
||||
row.appendChild(copyBtn(mlink, 'Copy matched link'));
|
||||
row.appendChild(linkBtn(VID_BASE + v.angle + '.mp4', 'Download 16:9'));
|
||||
row.appendChild(linkBtn(VID_BASE + v.angle + '-portrait.mp4', 'Download 9:16'));
|
||||
card.appendChild(row);
|
||||
const cap = fillLink(v.caption, link);
|
||||
const extras = [
|
||||
linkBtn('https://twitter.com/intent/tweet?text=' + encodeURIComponent(cap), 'Post on X'),
|
||||
linkBtn('https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(mlink), 'Share on Facebook'),
|
||||
linkBtn('https://t.me/share/url?url=' + encodeURIComponent(mlink) + '&text=' + encodeURIComponent(cap.replace(/https?:\/\/\S+$/, '').trim()), 'Telegram'),
|
||||
linkBtn('https://wa.me/?text=' + encodeURIComponent(cap), 'WhatsApp')
|
||||
];
|
||||
card.appendChild(block(cap, '<span class="lab">Caption</span>', extras));
|
||||
vw.appendChild(card);
|
||||
}
|
||||
}
|
||||
// objections
|
||||
const ob = $('promoObjections');
|
||||
if (ob && ob.dataset.filled !== link) {
|
||||
ob.dataset.filled = link; ob.innerHTML = '';
|
||||
for (const o of OBJECTIONS) {
|
||||
const d = document.createElement('details'); d.className = 'obj';
|
||||
const s = document.createElement('summary'); s.textContent = o.q; d.appendChild(s);
|
||||
const body = document.createElement('div'); body.className = 'obj-body';
|
||||
const t = document.createElement('div'); t.className = 'obj-truth';
|
||||
t.innerHTML = '<span class="lab">The truth</span><p>' + esc(o.truth) + '</p>'; body.appendChild(t);
|
||||
const say = fillLink(o.say, link);
|
||||
const sayEl = block(say, '<span class="lab lab-say">Say this</span>');
|
||||
sayEl.classList.add('obj-say'); body.appendChild(sayEl);
|
||||
d.appendChild(body); ob.appendChild(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { fill, POSTS, TEXTS, SWIPES, OBJECTIONS, VIDEOS };
|
||||
})();
|
||||
@@ -0,0 +1,75 @@
|
||||
// Paid shorts: a full-screen vertical feed over the video-ad inventory. You must
|
||||
// watch each short's required time (server-clock enforced, no seek) to earn,
|
||||
// then advance to the next. Reuses /api/my/videos + /api/my/videowatch.
|
||||
(function () {
|
||||
const $ = id => document.getElementById(id);
|
||||
const st = { token: null, secs: 0, maxSeen: 0, credited: false, done: false, skips: 0 };
|
||||
function j(url, body) {
|
||||
return fetch(url, body
|
||||
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
||||
: undefined).then(r => r.json());
|
||||
}
|
||||
async function load() {
|
||||
st.token = null; st.maxSeen = 0; st.credited = false; st.done = false;
|
||||
$('shOver').hidden = true; $('shCta').hidden = true; $('shNext').hidden = true;
|
||||
let r = null;
|
||||
// signed-out visitors get the sign-in note without a 401 round trip
|
||||
let me = null; try { me = await j('/api/me'); } catch (e) {}
|
||||
if (me && me.signedIn && me.email) { try { r = await j('/api/my/videos?orientation=portrait'); } catch (e) {} }
|
||||
if (!r || r.error) { $('shVideo').hidden = true; $('shMsg').hidden = false; $('shMsg').textContent = 'Sign in on the dashboard to watch shorts and earn.'; return; }
|
||||
$('shStat').textContent = 'today: ' + (r.status.count || 0) + ' / ' + r.status.cap;
|
||||
if (!r.ad) {
|
||||
$('shVideo').hidden = true; $('shMsg').hidden = false;
|
||||
$('shMsg').textContent = r.status.left <= 0 ? 'That\'s today\'s shorts. Come back tomorrow.' : 'No shorts in rotation right now. Check back soon.';
|
||||
return;
|
||||
}
|
||||
st.token = r.token; st.secs = r.ad.watchSecs; st.adId = r.ad.id;
|
||||
const v = $('shVideo');
|
||||
$('shMsg').hidden = true; v.hidden = false;
|
||||
v.src = r.ad.videoUrl; v.currentTime = 0;
|
||||
// safety net: this reel is portrait-only. If a landscape video slips through, skip to the next.
|
||||
v.onloadedmetadata = () => {
|
||||
if (v.videoWidth && v.videoHeight && v.videoWidth > v.videoHeight) {
|
||||
if (st.skips++ < 4) { load(); return; }
|
||||
v.hidden = true; $('shMsg').hidden = false; $('shMsg').textContent = 'No shorts in rotation right now. Check back soon.';
|
||||
} else { st.skips = 0; }
|
||||
};
|
||||
$('shTitle').textContent = r.ad.title || '';
|
||||
$('shCta').href = r.ad.ctaUrl; $('shCta').textContent = r.ad.ctaLabel || 'Learn more';
|
||||
$('shOver').hidden = false; $('shCta').hidden = false;
|
||||
v.onseeking = () => { if (v.currentTime > st.maxSeen + 0.5) v.currentTime = st.maxSeen; };
|
||||
v.ontimeupdate = () => {
|
||||
if (v.currentTime > st.maxSeen) st.maxSeen = v.currentTime;
|
||||
const left = Math.max(0, Math.ceil(st.secs - st.maxSeen));
|
||||
$('shTimer').textContent = left > 0 ? 'Watch ' + left + 's more to earn' : 'Earned — swipe to the next';
|
||||
if (!st.done && st.maxSeen >= st.secs) { st.done = true; credit(); }
|
||||
};
|
||||
v.play().catch(() => {});
|
||||
}
|
||||
async function credit() {
|
||||
if (st.credited) return; st.credited = true;
|
||||
try {
|
||||
const r = await j('/api/my/videowatch', { token: st.token });
|
||||
if (r.credited) { $('shStat').textContent = 'today: ' + (r.status.count || 0) + ' / ' + (r.status.cap || 0) + ' · +' + r.credited; }
|
||||
} catch (e) {}
|
||||
$('shNext').hidden = false;
|
||||
}
|
||||
$('shNext').addEventListener('click', load);
|
||||
$('shReport').addEventListener('click', e => {
|
||||
e.preventDefault(); if (!st.adId) return;
|
||||
const reason = (prompt('Report this short: broken, inappropriate, spam, scam, or other', 'inappropriate') || '').trim().toLowerCase();
|
||||
if (!reason) return;
|
||||
fetch('/api/report-ad', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ campaignId: st.adId, reason }) })
|
||||
.then(() => { $('shReport').textContent = '✓ reported — thanks'; }).catch(() => {});
|
||||
});
|
||||
// presence enforcement: pause when the tab/window loses focus, resume on return
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
const v = $('shVideo'); if (!v || !v.src) return;
|
||||
if (document.hidden) v.pause(); else if (!st.done) v.play().catch(() => {});
|
||||
});
|
||||
window.addEventListener('blur', () => { const v = $('shVideo'); if (v && v.src) v.pause(); });
|
||||
window.addEventListener('focus', () => { const v = $('shVideo'); if (v && v.src && !st.done) v.play().catch(() => {}); });
|
||||
// tap the video to pause/resume
|
||||
$('shVideo').addEventListener('click', () => { const v = $('shVideo'); if (v.paused) v.play(); else v.pause(); });
|
||||
load();
|
||||
})();
|
||||
@@ -0,0 +1,750 @@
|
||||
/* LinkSpin visual system v4 — built to Marty's picked reference
|
||||
(Behance 243562211 "ChainLock"): near-black ground, ONE neon-mint accent,
|
||||
sweeping light-trail hero, glowing icon plates, mockup-anchored sections,
|
||||
ghost wordmark footer. Discipline over decoration. */
|
||||
:root{
|
||||
--ground:#040807; --ground2:#071009;
|
||||
--panel:rgba(16,28,24,.55); --panel-solid:#0b1512;
|
||||
--line:rgba(84,150,128,.16); --line-strong:rgba(84,150,128,.36);
|
||||
--ink:#eef7f3; --muted:#8ba69c;
|
||||
--mint:#43e8c3; --mint-hi:#8ffbe3; --mint-ink:#03211a;
|
||||
--mint-soft:rgba(67,232,195,.09); --bad:#ff8f7d;
|
||||
--cyan:#54ccff; --violet:#9d7dff; --amber:#ffb238;
|
||||
--mono:"Consolas","JetBrains Mono",monospace;
|
||||
--disp:"Sora","Segoe UI",system-ui,sans-serif;
|
||||
--radius:18px;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
[hidden]{display:none!important} /* beats any display: set by a class (chat panel bug) */
|
||||
html{scroll-behavior:smooth}
|
||||
body{margin:0;background:var(--ground);color:var(--ink);font:16px/1.65 "Segoe UI",system-ui,sans-serif;overflow-x:hidden}
|
||||
/* topo-contour ground texture, very faint */
|
||||
body::before{content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;opacity:.5;
|
||||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='560' height='560' viewBox='0 0 560 560'%3E%3Cg fill='none' stroke='%2343e8c3' stroke-opacity='.05'%3E%3Cpath d='M60 280c40-90 160-130 220-90s60 150 160 150 120-90 120-90'/%3E%3Cpath d='M40 340c60-110 190-160 260-110s60 170 180 170'/%3E%3Cpath d='M20 400c80-130 220-190 300-130s60 190 200 190'/%3E%3Cpath d='M80 220c30-70 130-100 180-70s50 120 130 120 100-70 100-70'/%3E%3C/g%3E%3C/svg%3E")}
|
||||
a{color:var(--mint);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
.wrap{max-width:1100px;margin:0 auto;padding:0 22px}
|
||||
h1,h2,h3{font-family:var(--disp);letter-spacing:-.015em}
|
||||
/* ── nav: capsule pill ─────────────────────────────── */
|
||||
nav{position:sticky;top:0;z-index:10;background:rgba(4,8,7,.7);backdrop-filter:blur(16px);
|
||||
-webkit-backdrop-filter:blur(16px);border-bottom:1px solid var(--line)}
|
||||
nav .wrap{display:flex;align-items:center;gap:20px;min-height:66px;flex-wrap:wrap;padding-top:8px;padding-bottom:8px}
|
||||
.logo{font-family:var(--disp);font-weight:800;font-size:20px;color:var(--ink)}
|
||||
.logo b{color:var(--mint)}
|
||||
.logo-wrap{display:flex;flex-direction:column;gap:3px;min-width:0}
|
||||
.byline{display:block;font-size:10.5px;line-height:1.2;letter-spacing:.05em;color:var(--muted);white-space:nowrap}
|
||||
.byline b{color:var(--ink);font-weight:600}
|
||||
.bo-side .byline{padding:4px 8px 10px;white-space:normal}
|
||||
@media (max-width:560px){nav .byline{font-size:9.5px}}
|
||||
nav .links{display:flex;gap:4px;flex:1;flex-wrap:wrap;background:rgba(16,28,24,.7);border:1px solid var(--line);
|
||||
border-radius:999px;padding:5px 8px;width:fit-content;flex:0 1 auto;margin:0 auto}
|
||||
nav .links a{color:var(--muted);font-size:13.5px;font-weight:600;padding:7px 14px;border-radius:999px}
|
||||
nav .links a:hover{color:var(--ink);text-decoration:none}
|
||||
nav .links a.active{color:var(--mint);background:rgba(67,232,195,.1)}
|
||||
#navWallet{font-size:13px;margin-left:auto}
|
||||
.rehearsal{background:rgba(67,232,195,.07);border-bottom:1px solid var(--line);color:var(--muted);
|
||||
text-align:center;font-size:12.5px;padding:7px 12px}
|
||||
.rehearsal b{color:var(--mint)}
|
||||
/* ── buttons ───────────────────────────────────────── */
|
||||
.btn{display:inline-block;background:var(--mint);color:var(--mint-ink);border:0;border-radius:999px;
|
||||
padding:13px 26px;font-weight:700;font-size:15px;cursor:pointer;font-family:var(--disp);
|
||||
box-shadow:0 4px 30px rgba(67,232,195,.35);transition:transform .16s ease,box-shadow .16s ease,background .16s ease}
|
||||
.btn:hover{transform:translateY(-2px);background:var(--mint-hi);box-shadow:0 8px 44px rgba(67,232,195,.5);text-decoration:none}
|
||||
.btn.sec{background:transparent;color:var(--mint);border:1px solid rgba(67,232,195,.6);box-shadow:none}
|
||||
.btn.sec:hover{background:rgba(67,232,195,.08);box-shadow:0 4px 26px rgba(67,232,195,.2)}
|
||||
.btn.small{padding:8px 17px;font-size:13px}
|
||||
.btn:disabled{opacity:.45;cursor:default;transform:none;box-shadow:none}
|
||||
/* ── hero: centered, light-trail set-piece ─────────── */
|
||||
.hero{position:relative;text-align:center;padding:120px 0 84px;overflow:visible}
|
||||
.hero h1{font-size:clamp(36px,5.4vw,62px);font-weight:700;line-height:1.08;margin:0 auto 18px;max-width:820px;text-wrap:balance}
|
||||
.hero h1 em{font-style:normal;color:var(--mint)}
|
||||
.hero p.lead{color:var(--muted);font-size:17.5px;max-width:600px;margin:0 auto 32px}
|
||||
.hero p.lead b{color:var(--ink)}
|
||||
.hero .ctas{display:flex;gap:14px;justify-content:center;flex-wrap:wrap}
|
||||
/* neon arcs behind the hero text */
|
||||
.arcs{position:absolute;inset:-40px 0 0 0;z-index:-1;pointer-events:none}
|
||||
.arcs svg{width:100%;height:100%;overflow:visible}
|
||||
.arc{fill:none;stroke:url(#arcGrad);stroke-width:5;stroke-linecap:round;filter:url(#arcGlow)}
|
||||
.arc2{fill:none;stroke:url(#arcGrad);stroke-width:3;stroke-linecap:round;filter:url(#arcGlow);opacity:.8}
|
||||
/* falling glow streaks */
|
||||
.streak{position:absolute;top:-140px;width:1.5px;height:110px;pointer-events:none;
|
||||
background:linear-gradient(180deg,transparent,rgba(143,251,227,.8));border-radius:2px;opacity:0}
|
||||
@media(prefers-reduced-motion:no-preference){
|
||||
.streak{animation:fall 7s linear infinite}
|
||||
@keyframes fall{0%{transform:translateY(0);opacity:0}12%{opacity:.7}55%{opacity:.5}75%{transform:translateY(72vh);opacity:0}100%{transform:translateY(72vh);opacity:0}}
|
||||
}
|
||||
/* ── counters ──────────────────────────────────────── */
|
||||
.stats{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;margin:60px auto 0;max-width:900px}
|
||||
@media(min-width:760px){.stats{grid-template-columns:repeat(4,1fr)}}
|
||||
.stat{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:18px 12px;text-align:center}
|
||||
.stat .n{font-family:var(--disp);font-size:30px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--mint)}
|
||||
.stat .l{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;margin-top:3px}
|
||||
/* ── sections ──────────────────────────────────────── */
|
||||
section{padding:64px 0 8px}
|
||||
.sectionhead{text-align:center;max-width:640px;margin:0 auto 40px}
|
||||
.cmp{width:100%;border-collapse:separate;border-spacing:0;font-size:15px;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
|
||||
.cmp th,.cmp td{padding:13px 16px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left}
|
||||
.cmp th{font-family:var(--disp);font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
|
||||
.cmp th:last-child,.cmp td:last-child{color:var(--ink);background:rgba(67,232,195,.06)}
|
||||
.cmp td:first-child{font-weight:700;white-space:nowrap}
|
||||
.cmp td:nth-child(2){color:var(--muted)}
|
||||
.cmp tr:last-child td{border-bottom:0}
|
||||
@media (max-width:640px){.cmp td:first-child{white-space:normal}.cmp th,.cmp td{padding:10px 10px;font-size:14px}}
|
||||
h2{font-size:clamp(26px,3.2vw,36px);font-weight:700;margin:0 0 12px;text-wrap:balance}
|
||||
.sectionhead p{color:var(--muted);font-size:15.5px;margin:0}
|
||||
h3{font-size:16.5px;margin:0 0 8px;font-weight:700}
|
||||
.muted{color:var(--muted)}
|
||||
.small{font-size:13.5px}
|
||||
.mono{font-family:var(--mono)}
|
||||
/* icon-plate feature cards */
|
||||
.plates{display:grid;gap:16px;grid-template-columns:1fr}
|
||||
@media(min-width:760px){.plates{grid-template-columns:1fr 1fr 1fr}}
|
||||
.platecard{background:linear-gradient(170deg,rgba(22,38,32,.6),rgba(10,18,15,.7));border:1px solid var(--line);
|
||||
border-radius:var(--radius);padding:26px 22px;text-align:center;transition:border-color .2s ease,transform .2s ease}
|
||||
.platecard:hover{border-color:var(--line-strong);transform:translateY(-4px)}
|
||||
.plate{width:88px;height:88px;margin:0 auto 18px;border-radius:22px;display:grid;place-items:center;
|
||||
background:linear-gradient(160deg,#12241e,#0a1411);border:1px solid var(--line-strong);
|
||||
box-shadow:0 0 34px rgba(67,232,195,.15),inset 0 1px 0 rgba(143,251,227,.15)}
|
||||
.plate svg{width:38px;height:38px;stroke:var(--mint);fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
|
||||
.platecard p{color:var(--muted);font-size:14px;margin:0}
|
||||
/* two-col flank layout (icons around a central mockup) */
|
||||
.flank{display:grid;gap:26px;align-items:center;grid-template-columns:1fr}
|
||||
@media(min-width:920px){.flank{grid-template-columns:1fr 1.15fr 1fr}}
|
||||
.flankitem{margin:0 0 26px}
|
||||
.flankitem .plate{width:52px;height:52px;border-radius:14px;margin:0 0 12px}
|
||||
.flankitem .plate svg{width:24px;height:24px}
|
||||
.flankitem p{color:var(--muted);font-size:13.5px;margin:0}
|
||||
/* ledger mockup frame */
|
||||
.mockup{background:linear-gradient(170deg,#0e1c17,#080f0c);border:1px solid var(--line-strong);border-radius:20px;
|
||||
box-shadow:0 30px 80px rgba(0,0,0,.6),0 0 60px rgba(67,232,195,.08);overflow:hidden}
|
||||
.mockup .bar{display:flex;gap:6px;align-items:center;padding:11px 14px;border-bottom:1px solid var(--line)}
|
||||
.mockup .bar i{width:9px;height:9px;border-radius:50%;background:#1e3a31;display:inline-block}
|
||||
.mockup .bar .addr{font-family:var(--mono);font-size:11px;color:var(--muted);margin-left:8px;background:rgba(4,8,7,.6);
|
||||
border-radius:999px;padding:3px 12px}
|
||||
.mockup .body{padding:6px 0}
|
||||
.mockup .mrow{display:flex;justify-content:space-between;gap:10px;padding:9px 16px;font-family:var(--mono);
|
||||
font-size:12px;border-bottom:1px solid rgba(84,150,128,.08)}
|
||||
.mockup .mrow span:last-child{color:var(--mint)}
|
||||
.mockup .mrow.dim span:last-child{color:var(--muted)}
|
||||
/* 2-col story block: iso illustration + checklist */
|
||||
.story{display:grid;gap:36px;align-items:center;grid-template-columns:1fr}
|
||||
@media(min-width:880px){.story{grid-template-columns:1fr 1fr}}
|
||||
.checks{list-style:none;padding:0;margin:14px 0 0}
|
||||
.checks li{padding:7px 0 7px 30px;position:relative;color:var(--muted);font-size:14.5px}
|
||||
.checks li::before{content:"✓";position:absolute;left:0;top:6px;width:20px;height:20px;border-radius:50%;
|
||||
background:var(--mint-soft);color:var(--mint);font-size:12px;font-weight:700;display:grid;place-items:center;
|
||||
border:1px solid rgba(67,232,195,.35)}
|
||||
.chips{display:flex;gap:8px;margin:14px 0 4px;flex-wrap:wrap}
|
||||
.chip-t{border:1px solid var(--line-strong);border-radius:999px;padding:5px 16px;font-size:12.5px;color:var(--muted);font-family:var(--disp);font-weight:600}
|
||||
.chip-t.on{background:var(--mint);color:var(--mint-ink);border-color:var(--mint)}
|
||||
.iso{filter:drop-shadow(0 24px 50px rgba(0,0,0,.5))}
|
||||
/* level cycler: light the paying generation */
|
||||
#genViz .edge path{stroke:#22523f;transition:stroke .5s ease}
|
||||
#genViz .node-o{fill:#122e25;stroke:#2f6b55;stroke-width:1.3;transition:stroke .5s ease,fill .5s ease}
|
||||
#genViz .node-d{fill:#3f8f74;transition:fill .5s ease}
|
||||
#genViz[data-lvl="1"] .e1 path,#genViz[data-lvl="2"] .e2 path,#genViz[data-lvl="3"] .e3 path{stroke:#43e8c3}
|
||||
#genViz[data-lvl="1"] .g1 .node-o,#genViz[data-lvl="2"] .g2 .node-o,#genViz[data-lvl="3"] .g3 .node-o{stroke:#43e8c3;fill:#11362b}
|
||||
#genViz[data-lvl="1"] .g1 .node-d,#genViz[data-lvl="2"] .g2 .node-d,#genViz[data-lvl="3"] .g3 .node-d{fill:#43e8c3}
|
||||
.chips button{cursor:pointer;background:transparent;font:inherit}
|
||||
.chips button.chip-t{border:1px solid var(--line-strong);color:var(--muted)}
|
||||
.chips button.chip-t.on{background:var(--mint);color:var(--mint-ink);border-color:var(--mint)}
|
||||
/* pricing tiles: mint discipline */
|
||||
.tiles{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(185px,1fr));align-items:stretch}
|
||||
.tile{position:relative;background:linear-gradient(170deg,rgba(22,38,32,.55),rgba(10,18,15,.65));border:1px solid var(--line);
|
||||
border-radius:var(--radius);padding:26px 18px;text-align:center;display:flex;flex-direction:column;gap:6px;
|
||||
transition:transform .2s ease,border-color .2s ease}
|
||||
.tile:hover{transform:translateY(-5px);border-color:var(--line-strong)}
|
||||
.tile .name{font-family:var(--disp);font-weight:700;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em}
|
||||
.tile .price{font-family:var(--disp);font-size:36px;font-weight:700}
|
||||
.tile .cr{color:var(--mint);font-weight:700;font-size:14.5px}
|
||||
.tile .bonus{font-size:12px;color:var(--mint-hi)}
|
||||
.tile .pol{font-family:var(--mono);font-size:12px;color:var(--muted);flex:1}
|
||||
.tile.hot{border:1px solid rgba(67,232,195,.65);box-shadow:0 0 44px rgba(67,232,195,.14)}
|
||||
.tile.hot::before{content:"MOST POPULAR";position:absolute;top:-11px;left:50%;transform:translateX(-50%);
|
||||
background:var(--mint);color:var(--mint-ink);font-family:var(--disp);font-size:10px;font-weight:800;
|
||||
letter-spacing:.12em;border-radius:999px;padding:4px 14px}
|
||||
/* split strip: mint opacities */
|
||||
.split{display:flex;gap:8px;margin:18px 0}
|
||||
.split div{border-radius:12px;padding:16px 6px;text-align:center;font-size:13px;font-weight:700;font-family:var(--disp);
|
||||
border:1px solid var(--line-strong);color:var(--mint)}
|
||||
.split .s50{flex:5;background:rgba(67,232,195,.16);box-shadow:0 0 30px rgba(67,232,195,.12)}
|
||||
.split .s20{flex:2;background:rgba(67,232,195,.09)}
|
||||
.split .s10{flex:1;background:rgba(67,232,195,.05)}
|
||||
.split .sa{flex:2;background:rgba(139,166,156,.08);color:var(--muted)}
|
||||
section[id]{scroll-margin-top:84px} /* anchored menu targets clear the sticky nav */
|
||||
/* ticker */
|
||||
.ticker{overflow:hidden;contain:layout paint;border-top:1px solid var(--line);border-bottom:1px solid var(--line);
|
||||
background:rgba(67,232,195,.04);white-space:nowrap;padding:10px 0;
|
||||
-webkit-mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent);
|
||||
mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent)}
|
||||
.ticker .inner{display:inline-block;font-family:var(--mono);font-size:12.5px;padding-left:100%;animation:tick 120s linear infinite}
|
||||
.ticker .inner span{margin-right:48px;color:var(--muted)}
|
||||
.ticker .inner span b{color:var(--mint);font-weight:600}
|
||||
@keyframes tick{to{transform:translateX(-100%)}}
|
||||
@media(prefers-reduced-motion:reduce){.ticker .inner{animation:none;padding-left:0}}
|
||||
/* cards / tables / feed (inner pages) */
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:24px;margin:0 0 16px}
|
||||
.card:hover{border-color:var(--line-strong)}
|
||||
.grid{display:grid;gap:16px}
|
||||
.grid>*,.flank>*,.story>*{min-width:0} /* let grid children shrink so inner tablewraps scroll instead of widening the page */
|
||||
@media(min-width:760px){.grid.c3{grid-template-columns:1fr 1fr 1fr}.grid.c2{grid-template-columns:1fr 1fr}}
|
||||
table{width:100%;border-collapse:collapse;font-size:15px}
|
||||
th,td{text-align:left;padding:12px 14px;border-bottom:1px solid var(--line);vertical-align:middle}
|
||||
th{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.1em;font-family:var(--disp)}
|
||||
td.num,th.num{font-variant-numeric:tabular-nums}
|
||||
tr:last-child td{border-bottom:0}
|
||||
.tablewrap{overflow-x:auto}
|
||||
.feed{font-family:var(--mono);font-size:13.5px;line-height:1.7}
|
||||
.feed .row{padding:9px 14px;border-bottom:1px solid var(--line);display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
|
||||
.feed .row:first-child{background:var(--mint-soft)}
|
||||
.feed .row .when{font-family:var(--mono);font-size:11px;color:var(--muted);white-space:nowrap;min-width:92px}
|
||||
.feed .t-Purchase,.feed .t-AwardPaid{color:var(--mint-hi)}
|
||||
.feed .t-TierPaid{color:var(--mint)}
|
||||
.feed .t-AdminPaid{color:var(--muted)}
|
||||
.feed .tx a{color:var(--muted);font-size:12px}
|
||||
.badge{display:inline-block;background:var(--mint-soft);color:var(--mint);border:1px solid rgba(67,232,195,.35);
|
||||
border-radius:999px;padding:3px 13px;font-size:12px;font-weight:700}
|
||||
.spend-banner{background:linear-gradient(160deg,rgba(242,201,76,.14),rgba(242,201,76,.04));border-color:rgba(242,201,76,.55);text-align:center;padding:22px 20px}
|
||||
.spend-banner .lbl{font-family:var(--disp);font-size:13px;letter-spacing:.14em;text-transform:uppercase;color:#f2c94c}
|
||||
.spend-banner .big{font-family:var(--disp);font-size:clamp(40px,6vw,64px);font-weight:800;color:#f2c94c;line-height:1.05;margin:6px 0 4px;font-variant-numeric:tabular-nums}
|
||||
.spend-banner .big .unit{font-size:20px;font-weight:700;color:var(--ink)}
|
||||
.spend-banner .sub{color:var(--ink);font-size:15px}
|
||||
.spend-banner .rates{display:flex;gap:8px 18px;flex-wrap:wrap;justify-content:center;margin-top:12px;font-size:12.5px;color:var(--muted)}
|
||||
.spend-banner .rates b{color:var(--ink)}
|
||||
.boot-spin{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;min-height:60vh}
|
||||
.boot-spin .ring{width:46px;height:46px;border-radius:50%;border:4px solid rgba(67,232,195,.18);border-top-color:var(--mint);animation:bootspin .9s linear infinite}
|
||||
@keyframes bootspin{to{transform:rotate(360deg)}}
|
||||
.bo-top{flex-wrap:wrap}
|
||||
.ad-strip{flex:1 1 100%;font-size:13.5px;padding:8px 0 2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ad-strip a{color:var(--ink)}
|
||||
.ad-strip b{color:var(--mint)}
|
||||
.ad-foot{text-align:center}
|
||||
.pv{border:1px solid var(--line);border-radius:14px;padding:16px 18px;margin:0 0 16px;background:rgba(4,8,7,.35)}
|
||||
.pv-head b{display:block;font-size:18px;margin:4px 0 2px}
|
||||
.done-big{display:flex;flex-direction:column;align-items:center;gap:6px;padding:26px 16px;text-align:center}
|
||||
.done-big .tick{width:84px;height:84px;border-radius:50%;display:grid;place-items:center;font-size:48px;font-weight:800;color:var(--mint-ink);background:var(--mint);box-shadow:0 0 0 10px rgba(67,232,195,.15),0 10px 30px rgba(67,232,195,.35)}
|
||||
.done-big b{font-family:var(--disp);font-size:22px;margin-top:8px}
|
||||
.badge.amber{background:rgba(139,166,156,.1);border-color:rgba(139,166,156,.4);color:var(--muted)}
|
||||
/* faq */
|
||||
details{border:1px solid var(--line);border-radius:14px;margin:0 0 10px;background:var(--panel)}
|
||||
details[open]{border-color:rgba(67,232,195,.45)}
|
||||
summary{cursor:pointer;padding:16px 20px;font-weight:700;font-family:var(--disp);font-size:15px;list-style:none}
|
||||
summary::before{content:"+";color:var(--mint);font-weight:800;margin-right:12px}
|
||||
details[open] summary::before{content:"−"}
|
||||
details p{margin:0;padding:0 20px 18px 42px;color:var(--muted);font-size:14.5px}
|
||||
/* closing cta card */
|
||||
.closer{position:relative;display:grid;gap:26px;align-items:center;grid-template-columns:1fr;
|
||||
padding:44px 36px;border-radius:24px;overflow:hidden;margin:80px 0 0;
|
||||
background:linear-gradient(160deg,#0d1d17,#060c0a);border:1px solid var(--line-strong)}
|
||||
@media(min-width:880px){.closer{grid-template-columns:1.2fr .8fr}}
|
||||
.closer::before{content:"";position:absolute;width:460px;height:460px;right:-140px;top:-260px;border-radius:50%;
|
||||
background:radial-gradient(circle,rgba(67,232,195,.2),transparent 65%);pointer-events:none}
|
||||
.closer h2{margin:0 0 12px}
|
||||
/* footer + ghost wordmark */
|
||||
footer{border-top:1px solid var(--line);margin-top:90px;padding:34px 0 0;color:var(--muted);font-size:13.5px;overflow:hidden}
|
||||
.ghost{font-family:var(--disp);font-weight:800;font-size:clamp(64px,13vw,170px);line-height:.9;text-align:center;
|
||||
margin:34px 0 -20px;letter-spacing:-.02em;user-select:none;
|
||||
background:linear-gradient(180deg,rgba(67,232,195,.14),transparent 78%);
|
||||
-webkit-background-clip:text;background-clip:text;color:transparent;white-space:nowrap}
|
||||
/* misc */
|
||||
#status{position:fixed;left:50%;transform:translateX(-50%);bottom:22px;background:var(--panel-solid);
|
||||
border:1px solid var(--line-strong);border-radius:14px;padding:13px 22px;font-size:14px;max-width:90vw;z-index:30;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,.55)}
|
||||
#status.ok{border-color:var(--mint)}
|
||||
#status.bad{border-color:var(--bad)}
|
||||
input,select,textarea{background:rgba(4,8,7,.65);border:1px solid var(--line-strong);color:var(--ink);border-radius:11px;
|
||||
padding:11px 14px;font-size:14.5px;font-family:inherit}
|
||||
input[type=range]{padding:0;border:0;background:transparent;accent-color:var(--mint);height:28px;vertical-align:middle}
|
||||
input:focus,select:focus,textarea:focus{border-color:var(--mint)}
|
||||
textarea{resize:vertical;font:inherit}
|
||||
:focus-visible{outline:2px solid var(--mint);outline-offset:2px}
|
||||
.hero-note{font-family:var(--mono);font-size:12px;color:var(--muted);margin-top:26px}
|
||||
/* ── member back-office shell ─────────────────────────── */
|
||||
.bo-body{background:var(--ground)}
|
||||
.bo{display:grid;grid-template-columns:236px 1fr;min-height:100vh}
|
||||
.bo-side{position:sticky;top:0;height:100vh;overflow-y:auto;scrollbar-width:none;-ms-overflow-style:none;display:flex;flex-direction:column;gap:22px;
|
||||
padding:22px 16px;background:rgba(10,18,15,.92);border-right:1px solid var(--line)}
|
||||
.bo-side .logo{font-size:19px;padding:0 8px}
|
||||
.bo-menu{display:flex;flex-direction:column;gap:4px}
|
||||
.bo-menu button{display:flex;align-items:center;gap:11px;background:transparent;border:0;color:var(--muted);
|
||||
font:600 14px var(--disp);padding:11px 12px;border-radius:11px;cursor:pointer;text-align:left;
|
||||
border-left:3px solid transparent}
|
||||
.bo-menu button svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto}
|
||||
.bo-menu button:hover{color:var(--ink);background:rgba(67,232,195,.05)}
|
||||
.bo-menu button.on{color:var(--mint);background:rgba(67,232,195,.09);border-left-color:var(--mint)}
|
||||
.bo-links{display:flex;flex-direction:column;gap:2px;padding:14px 12px;border-top:1px solid var(--line)}
|
||||
.bo-cap{font-family:var(--mono);font-size:10.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-bottom:6px}
|
||||
.bo-links a{color:var(--muted);font-size:13.5px;padding:5px 0}
|
||||
.bo-links a:hover{color:var(--ink);text-decoration:none}
|
||||
.bo-foot{margin-top:auto;padding:14px 12px 0;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px;overflow-wrap:anywhere}
|
||||
.bo-main{min-width:0;display:flex;flex-direction:column}
|
||||
.bo-side::-webkit-scrollbar{width:0;height:0;display:none}
|
||||
.bo-pagefoot{margin-top:auto;padding:22px 26px;border-top:1px solid var(--line);display:flex;gap:20px;flex-wrap:wrap;justify-content:center;font-size:13px}
|
||||
.bo-pagefoot a{color:var(--muted)}
|
||||
.bo-pagefoot a:hover{color:var(--ink);text-decoration:none}
|
||||
.bo-top{display:flex;align-items:center;gap:16px;padding:16px 26px;border-bottom:1px solid var(--line);
|
||||
background:rgba(6,10,8,.75);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);position:sticky;top:0;z-index:5}
|
||||
.bo-top h2{font-size:20px}
|
||||
#boBurger{display:none;background:transparent;border:1px solid var(--line-strong);color:var(--ink);
|
||||
border-radius:9px;font-size:17px;padding:5px 11px;cursor:pointer}
|
||||
.bo-rehearsal{margin-left:auto;color:var(--muted)}
|
||||
.bo-rehearsal b{color:var(--mint)}
|
||||
.bo-content{padding:26px;max-width:1060px;width:100%}
|
||||
@media(max-width:959px){
|
||||
.bo{grid-template-columns:1fr}
|
||||
.bo-side{position:fixed;left:0;top:0;bottom:0;width:250px;z-index:20;transform:translateX(-100%);
|
||||
transition:transform .22s ease;height:100dvh}
|
||||
.bo.side-open .bo-side{transform:none;box-shadow:0 0 60px rgba(0,0,0,.6)}
|
||||
#boBurger{display:block}
|
||||
.bo-content{padding:18px}
|
||||
}
|
||||
/* ── promo banners ── */
|
||||
.promo-banners{display:flex;flex-direction:column;gap:10px;margin-top:12px}
|
||||
.pb-acc{margin:0}
|
||||
.pb-acc>summary{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:13px 16px;font-size:14.5px}
|
||||
.pb-acc>summary::before{content:none}
|
||||
.pb-acc>summary>span:first-child::before{content:"+";color:var(--mint);font-weight:800;margin-right:10px}
|
||||
.pb-acc[open]>summary>span:first-child::before{content:"−"}
|
||||
.pb-acc .pb-count{font-family:var(--mono);font-size:11px;color:var(--muted);white-space:nowrap}
|
||||
.pb-acc .pb-grid{display:flex;flex-direction:column;gap:14px;padding:0 16px 16px}
|
||||
.pb-item img{max-width:100%;border-radius:8px;border:1px solid var(--line-strong);display:block}
|
||||
.pb-item .pb-row{display:flex;gap:10px;align-items:center;margin-top:6px;flex-wrap:wrap}
|
||||
.pb-item .pb-size{font-family:var(--mono);font-size:11px;color:var(--muted)}
|
||||
/* ── featured day-slot picker ── */
|
||||
.feat-days{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.feat-day{flex:0 0 auto;min-width:82px;padding:8px 10px;border:1px solid var(--line-strong);border-radius:10px;
|
||||
background:var(--panel);cursor:pointer;text-align:center}
|
||||
.feat-day.on{border-color:var(--mint);box-shadow:0 0 0 1px var(--mint)}
|
||||
.feat-day.full{opacity:.45;cursor:not-allowed}
|
||||
.feat-day .fd-day{font-weight:700;font-size:12px}
|
||||
.feat-day .fd-occ{font-family:var(--mono);font-size:11px;color:var(--muted)}
|
||||
.feat-day.open2 .fd-occ{color:var(--mint)}
|
||||
/* ── featured rotation strip ── */
|
||||
.feat-top{max-width:760px;margin:16px auto;border:1px solid rgba(255,177,56,.5)}
|
||||
.feat-strip{display:flex;justify-content:center;margin-top:10px}
|
||||
.feat-strip a{max-width:728px;width:100%;display:flex;justify-content:space-between;gap:10px;padding:11px 14px;border-radius:10px;
|
||||
background:var(--panel);border:1px solid #ffb238;color:var(--ink);text-decoration:none;font-weight:600}
|
||||
.feat-strip a:hover{border-color:#ffd15c;box-shadow:0 0 0 1px rgba(255,177,56,.4)}
|
||||
.feat-strip .by{font-size:12px;color:var(--muted);font-weight:500;white-space:nowrap}
|
||||
.feat-strip .by{font-size:12px;color:var(--muted);font-weight:500;white-space:nowrap}
|
||||
/* ── achievement badges ── */
|
||||
.badges{display:flex;gap:18px;flex-wrap:wrap;margin-top:12px}
|
||||
.badge-a{display:flex;flex-direction:column;align-items:center;gap:6px;width:150px;text-align:center}
|
||||
.badge-a .badge-img{width:150px;height:150px;border-radius:14px;object-fit:cover;border:1px solid var(--line-strong)}
|
||||
.badge-a.locked .badge-img{filter:grayscale(.85) brightness(.5);opacity:.7}
|
||||
.badge-a .medal{width:76px;height:76px;border-radius:50%;display:grid;place-items:center;position:relative;
|
||||
background:radial-gradient(circle at 50% 35%,rgba(67,232,195,.28),rgba(9,26,19,.9));
|
||||
border:2px solid var(--mint);box-shadow:0 0 18px rgba(67,232,195,.3)}
|
||||
.badge-a.locked .medal{background:rgba(139,166,156,.08);border-color:var(--line-strong);box-shadow:none;filter:grayscale(1);opacity:.5}
|
||||
.badge-a .medal svg{width:38px;height:38px;stroke:var(--mint);fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
|
||||
.badge-a.locked .medal svg{stroke:var(--muted)}
|
||||
.badge-a .bl{font-size:12px;font-weight:700;line-height:1.2}
|
||||
.badge-a .bs{font-size:10.5px;color:var(--muted)}
|
||||
.badge-a .share{font-size:11px;color:var(--mint);cursor:pointer;background:none;border:0;padding:0;text-decoration:underline}
|
||||
/* ── "Your next move": milestone stepper card ── */
|
||||
.next-card{position:relative;overflow:hidden;border-color:rgba(67,232,195,.28)}
|
||||
.next-card::before{content:"";position:absolute;inset:0;pointer-events:none;background:
|
||||
radial-gradient(420px 150px at 10% 0%,rgba(67,232,195,.12),transparent 70%),
|
||||
radial-gradient(360px 130px at 90% 100%,rgba(157,125,255,.09),transparent 70%)}
|
||||
.nc-head{display:flex;gap:14px;align-items:flex-start;position:relative}
|
||||
.nc-head .pl{flex:0 0 auto;width:44px;height:44px;border-radius:12px;display:grid;place-items:center;
|
||||
background:rgba(67,232,195,.12);border:1px solid rgba(67,232,195,.35)}
|
||||
.nc-head .pl svg{width:20px;height:20px;stroke:var(--mint);fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||
.nc-steps{display:flex;margin-top:20px;position:relative;flex-wrap:wrap;row-gap:14px}
|
||||
.nc-step{flex:1;min-width:88px;display:flex;flex-direction:column;align-items:center;gap:7px;
|
||||
position:relative;text-align:center;padding:0 4px}
|
||||
.nc-step::before{content:"";position:absolute;top:13px;left:-50%;width:100%;height:2px;background:var(--line-strong)}
|
||||
.nc-step:first-child::before{display:none}
|
||||
.nc-step.hit::before{background:linear-gradient(90deg,var(--mint),var(--mint-hi))}
|
||||
.nc-step .dot{width:27px;height:27px;border-radius:50%;display:grid;place-items:center;font-size:12px;
|
||||
font-weight:800;background:var(--panel-solid);border:2px solid var(--line-strong);color:var(--muted);
|
||||
position:relative;z-index:1}
|
||||
.nc-step.hit .dot{background:var(--mint);border-color:var(--mint);color:var(--mint-ink);
|
||||
box-shadow:0 0 14px rgba(67,232,195,.35)}
|
||||
.nc-step.cur .dot{border-color:var(--amber);color:var(--amber);box-shadow:0 0 12px rgba(255,178,56,.4)}
|
||||
.nc-step .lb{font-size:12px;font-weight:700;line-height:1.25}
|
||||
.nc-step .lb i{display:block;font-style:normal;font-weight:500;font-size:10.5px;color:var(--muted);margin-top:2px}
|
||||
.nc-step.cur .lb{color:var(--amber)}
|
||||
/* ── overview v3: stat cards w/ plates+chips, hand-rolled charts ── */
|
||||
.statx{display:flex;gap:14px;align-items:flex-start;background:var(--panel);border:1px solid var(--line);
|
||||
border-radius:var(--radius);padding:18px;position:relative;overflow:hidden}
|
||||
.statx .pl{flex:0 0 auto;width:46px;height:46px;border-radius:13px;display:grid;place-items:center;
|
||||
background:rgba(67,232,195,.1);border:1px solid rgba(67,232,195,.3)}
|
||||
.statx .pl svg{width:22px;height:22px;stroke:var(--mint);fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||
.statx.c-cyan .pl{background:rgba(84,204,255,.1);border-color:rgba(84,204,255,.3)}
|
||||
.statx.c-cyan .pl svg{stroke:var(--cyan)}
|
||||
.statx.c-violet .pl{background:rgba(157,125,255,.12);border-color:rgba(157,125,255,.32)}
|
||||
.statx.c-violet .pl svg{stroke:var(--violet)}
|
||||
.statx.c-amber .pl{background:rgba(255,178,56,.1);border-color:rgba(255,178,56,.3)}
|
||||
.statx.c-amber .pl svg{stroke:var(--amber)}
|
||||
.statx .nv{font-family:var(--disp);font-size:26px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.1}
|
||||
.statx .lb{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-top:2px}
|
||||
.chip{display:inline-block;margin-top:7px;font-size:11.5px;font-weight:700;border-radius:999px;padding:2px 10px;
|
||||
background:rgba(67,232,195,.1);color:var(--mint)}
|
||||
.chip.flat{background:rgba(139,166,156,.12);color:var(--muted)}
|
||||
.chip.warm{background:rgba(255,178,56,.12);color:var(--amber)}
|
||||
.card-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px}
|
||||
.card-head .sub{font-size:12px;color:var(--muted)}
|
||||
/* bar chart (divs, real data) */
|
||||
.barchart{display:flex;align-items:flex-end;gap:6px;height:120px;margin-top:14px}
|
||||
.barchart .bar{flex:1;min-width:6px;background:linear-gradient(180deg,var(--mint),rgba(67,232,195,.25));
|
||||
border-radius:5px 5px 2px 2px;position:relative;transition:height .5s ease}
|
||||
.barchart .bar.alt{background:linear-gradient(180deg,var(--violet),rgba(157,125,255,.25))}
|
||||
.barchart .bar:hover::after{content:attr(data-v);position:absolute;bottom:calc(100% + 4px);left:50%;
|
||||
transform:translateX(-50%);background:var(--panel-solid);border:1px solid var(--line-strong);border-radius:7px;
|
||||
padding:2px 8px;font-family:var(--mono);font-size:11px;white-space:nowrap;z-index:3}
|
||||
.barchart .bar.empty{background:rgba(139,166,156,.15);height:4px!important}
|
||||
.chart-x{display:flex;gap:6px;margin-top:6px}
|
||||
.chart-x span{flex:1;text-align:center;font-family:var(--mono);font-size:10px;color:var(--muted);overflow:hidden;white-space:nowrap}
|
||||
/* donut + ring (SVG) */
|
||||
.donut-wrap{display:flex;gap:22px;align-items:center;margin-top:10px;flex-wrap:wrap}
|
||||
.donut-legend{display:flex;flex-direction:column;gap:8px;font-size:13px}
|
||||
.donut-legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:8px}
|
||||
.donut-center{font-family:var(--disp);font-weight:800}
|
||||
/* ── login ad interstitial: sponsor opens in a new tab, timer runs here ── */
|
||||
.lgate{position:fixed;inset:0;z-index:90;display:flex;flex-direction:column;background:var(--ground)}
|
||||
.lgate-bar{display:flex;align-items:center;gap:14px;padding:10px 16px;background:var(--panel-solid);
|
||||
border-bottom:1px solid var(--line);flex-wrap:wrap}
|
||||
.lg-brand{font-family:var(--disp);font-weight:800;font-size:15px;white-space:nowrap}
|
||||
.lg-brand em{color:var(--mint);font-style:normal}
|
||||
.lgate-note{flex:1;min-width:140px}
|
||||
.lg-timer{font-weight:700;font-size:14px;border:1px solid var(--line-strong);border-radius:999px;
|
||||
padding:5px 14px;min-width:150px;text-align:center}
|
||||
.lg-timer.done{color:var(--mint);border-color:var(--mint)}
|
||||
.lgate-body{flex:1;display:grid;place-items:center;padding:24px;overflow:auto}
|
||||
.lgate-card{max-width:560px;text-align:center}
|
||||
.lgate-card img{max-width:100%;max-height:50vh;border-radius:12px;border:1px solid var(--line-strong)}
|
||||
#lgCreative .lg-linkcard{display:inline-block;background:var(--panel);border:1px solid var(--line-strong);
|
||||
border-radius:14px;padding:22px 28px;font-family:var(--disp);font-weight:700;font-size:18px;overflow-wrap:anywhere}
|
||||
/* ── welcome tour (gauntlet): framed line sites + countdown ── */
|
||||
.lgate-frame{flex:1;border:0;width:100%;background:#fff;min-height:0}
|
||||
.ggmeta{padding:8px 16px;background:var(--panel-solid);border-bottom:1px solid var(--line)}
|
||||
/* ── bio header (wall/profile page) ── */
|
||||
.bio-head{display:flex;gap:22px;align-items:center;flex-wrap:wrap;padding:8px 0 4px}
|
||||
.bio-avatar{width:96px;height:96px;border-radius:50%;object-fit:cover;border:2px solid var(--mint);flex:0 0 auto}
|
||||
.bio-badge{width:74px;height:74px;border-radius:12px;object-fit:cover;flex:0 0 auto;border:1px solid var(--line-strong)}
|
||||
.btn.disabled{opacity:.45;pointer-events:none;filter:grayscale(.3)}
|
||||
.bio-meta{flex:1;min-width:220px}
|
||||
.bio-socials{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
|
||||
.bio-video{margin:14px auto 8px;max-width:760px}
|
||||
@media (max-width:720px){.bio-head{flex-direction:column;align-items:center;text-align:center;gap:14px}.bio-head .bio-avatar,.bio-head .bio-badge{margin:0 auto}.bio-meta{min-width:0;width:100%}.bio-socials{justify-content:center}.bio-qr{margin:4px auto 0}.bio-video .eyebrow{text-align:center}}
|
||||
.bio-video iframe,.bio-video video{width:100%;aspect-ratio:16/9;border:1px solid var(--line-strong);border-radius:16px;background:#000;display:block}
|
||||
.bio-socials a{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:700;color:var(--mint);border:1px solid var(--line-strong);
|
||||
border-radius:999px;padding:4px 12px 4px 9px;text-decoration:none}
|
||||
.bio-socials a svg{flex:0 0 auto}
|
||||
.bio-socials a:hover{border-color:var(--mint)}
|
||||
.bio-qr{flex:0 0 auto;background:var(--panel);border:1px solid var(--line-strong);border-radius:14px;padding:10px}
|
||||
.bio-qr img{display:block;border-radius:8px;background:#eef7f3}
|
||||
/* ── wall page ── */
|
||||
.wall-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px;text-align:center}
|
||||
.wall-card img{max-width:100%;border-radius:10px;border:1px solid var(--line-strong)}
|
||||
.wall-pos{font-family:var(--mono);font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px}
|
||||
.wc-creative img{max-width:100%;border-radius:10px;border:1px solid var(--line-strong)}
|
||||
.wc-action{margin-top:10px;min-height:32px;display:flex;justify-content:center;align-items:center}
|
||||
.wc-check{color:var(--mint);font-weight:800;border:1px solid var(--mint);border-radius:999px;padding:4px 14px;
|
||||
box-shadow:0 0 14px rgba(67,232,195,.3)}
|
||||
.wc-timer{color:var(--amber);font-weight:700;font-family:var(--mono);font-size:13px}
|
||||
/* ── modal (sponsor message) ── */
|
||||
.modal-back{position:fixed;inset:0;z-index:100;background:rgba(2,6,5,.72);display:flex;align-items:center;justify-content:center;padding:20px}
|
||||
.modal-card{background:var(--panel-solid);border:1px solid var(--line-strong);border-radius:var(--radius);
|
||||
padding:24px;max-width:520px;width:100%;box-shadow:0 20px 60px rgba(0,0,0,.5)}
|
||||
/* ── downline lineage list ── */
|
||||
.lin-lvl{margin:10px 0}
|
||||
.lin-lvl>.cap{display:inline-block;font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;
|
||||
color:var(--mint);background:var(--mint-soft);border:1px solid var(--line-strong);border-radius:8px;padding:4px 10px;margin-bottom:10px}
|
||||
.lin-row{display:grid;grid-template-columns:minmax(110px,1.2fr) minmax(0,2fr) auto auto auto auto;gap:6px 14px;
|
||||
align-items:center;padding:9px 0;border-bottom:1px solid var(--line)}
|
||||
.lin-row .nm{font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.lin-row .em{font-size:12px;color:var(--mint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.lin-row .id{font-family:var(--mono);font-size:11px;color:var(--muted)}
|
||||
.lin-row .sp{display:block;font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .lin-row .em{min-width:0}
|
||||
.lin-row .dt{font-size:12px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.lin-row .chat-msg-btn{justify-self:end}
|
||||
.lin-row .lin-act-btn{justify-self:end;white-space:nowrap}
|
||||
.lin-act{display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:8px 10px 10px;margin:-1px 0 6px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.025);border-radius:0 0 10px 10px}
|
||||
.lin-chip{font-size:12px;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:3px 10px;white-space:nowrap} .lin-chip b{font-weight:600;color:var(--ink);margin-right:5px} .lin-chip.on{border-color:rgba(67,232,195,.45);color:var(--ink)} .lin-chip.warn{border-color:rgba(255,209,92,.5)}
|
||||
.lin-verdict{margin-left:auto;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#ffd15c} .lin-verdict.on{color:var(--mint)}
|
||||
@media (max-width:560px){.lin-chip{white-space:normal}.lin-act{padding:8px 4px 10px}}
|
||||
@media (max-width:560px){.lin-row{grid-template-columns:1fr auto;gap:2px 10px}
|
||||
.lin-row .em{grid-column:1/2}.lin-row .dt{grid-column:1/2;text-align:left}.lin-row .chat-msg-btn{grid-row:1/3;grid-column:2}
|
||||
.lin-row .lin-act-btn{grid-column:2;grid-row:3/5}.lin-row:not(:has(.chat-msg-btn)) .lin-act-btn{grid-row:1/3}}
|
||||
/* ── solo composer: toolbar + contenteditable editor ── */
|
||||
.ed-bar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
|
||||
.ed-bar button{background:var(--panel);color:var(--ink);border:1px solid var(--line-strong);border-radius:8px;
|
||||
padding:5px 11px;font-size:12.5px;cursor:pointer}
|
||||
.ed-bar button:hover,.ed-bar button:focus-visible{border-color:var(--mint);outline:none}
|
||||
.ed-body{background:rgba(4,8,7,.65);border:1px solid var(--line-strong);border-radius:11px;
|
||||
min-height:180px;padding:12px 14px;outline:none;overflow-wrap:anywhere}
|
||||
.ed-body:focus{border-color:var(--mint)}
|
||||
.ed-body:empty::before{content:attr(data-ph);color:var(--muted)}
|
||||
.ed-body img,.ed-body video,.ib-rich img,.ib-rich video{max-width:100%;border-radius:10px;margin:6px 0;display:block}
|
||||
.ed-sep{width:1px;align-self:stretch;background:var(--line-strong);margin:2px 3px}
|
||||
.ed-bar sub{font-size:9px}
|
||||
textarea.ed-body{width:100%;min-height:180px;resize:vertical}
|
||||
.ed-body a,.ib-rich a{color:var(--mint)}
|
||||
.ed-body h3,.ib-rich h3,.ed-body h4,.ib-rich h4{margin:.5em 0 .3em}
|
||||
.ed-media{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0 0}
|
||||
/* ── earn sub-tabs (Watch ads | Inbox) ── */
|
||||
.subtabs{display:flex;gap:6px;margin:0 0 16px;border-bottom:1px solid var(--line);padding-bottom:0}
|
||||
.subtab{background:none;border:0;border-bottom:2px solid transparent;color:var(--muted);
|
||||
font:inherit;font-weight:700;font-size:14px;padding:8px 14px;cursor:pointer;margin-bottom:-1px;
|
||||
display:inline-flex;align-items:center;gap:7px}
|
||||
.subtab:hover{color:var(--ink)}
|
||||
.subtab.on{color:var(--mint);border-bottom-color:var(--mint)}
|
||||
.subtab .pill{position:static}
|
||||
/* ── solo-ads inbox ── */
|
||||
.bo-menu .pill{margin-left:auto;background:var(--amber);color:#1a1206;font-size:11px;font-weight:800;
|
||||
border-radius:999px;padding:1px 8px;line-height:1.5}
|
||||
.ib-row{display:flex;gap:10px;align-items:baseline;padding:11px 6px;border-bottom:1px solid var(--line);
|
||||
cursor:pointer;flex-wrap:wrap}
|
||||
.ib-row:hover{background:rgba(67,232,195,.05)}
|
||||
.ib-row .sub{font-weight:700;flex:1;min-width:160px;overflow-wrap:anywhere}
|
||||
.ib-row.unread .sub{color:var(--mint)}
|
||||
.ib-row .from,.ib-row .when{font-size:12px;color:var(--muted);white-space:nowrap}
|
||||
.cta-need{box-shadow:0 0 0 2px var(--amber),0 0 16px rgba(255,178,56,.4)!important;animation:ctapulse 1.6s ease-in-out infinite}
|
||||
@keyframes ctapulse{50%{box-shadow:0 0 0 2px var(--amber),0 0 24px rgba(255,178,56,.65)!important}}
|
||||
@media (prefers-reduced-motion:reduce){.cta-need{animation:none}}
|
||||
/* ── back-office accent family: green leads, cyan/violet/amber season the cards ── */
|
||||
.bo .stats .stat:nth-child(2) .n{color:var(--cyan)}
|
||||
.bo .stats .stat:nth-child(2)::before{background:linear-gradient(90deg,transparent,var(--cyan),transparent)}
|
||||
.bo .stats .stat:nth-child(3) .n{color:var(--violet)}
|
||||
.bo .stats .stat:nth-child(3)::before{background:linear-gradient(90deg,transparent,var(--violet),transparent)}
|
||||
.bo .stats .stat:nth-child(4) .n{color:var(--amber)}
|
||||
.bo .stats .stat:nth-child(4)::before{background:linear-gradient(90deg,transparent,var(--amber),transparent)}
|
||||
.bo .card h3::before{content:"";display:inline-block;width:9px;height:9px;border-radius:2.5px;
|
||||
background:var(--mint);margin-right:10px;transform:rotate(45deg);vertical-align:1px}
|
||||
#pane-line .card h3::before{background:var(--cyan)}
|
||||
#pane-buy .card h3::before,#pane-campaigns .card h3::before{background:var(--amber)}
|
||||
#pane-earn .card h3::before,#pane-earnings .card h3::before{background:var(--violet)}
|
||||
#pane-promo .card h3::before{background:var(--cyan)}
|
||||
#pane-wallet .card h3::before,#pane-profile .card h3::before{background:var(--mint)}
|
||||
.bo .card{background:linear-gradient(165deg,rgba(24,36,31,.6),rgba(13,19,17,.66))}
|
||||
#pane-line .qualbar,#nextCard{border-left:3px solid rgba(67,232,195,.4)}
|
||||
.promo-block{background:rgba(4,8,7,.55);border:1px solid var(--line);border-radius:12px;
|
||||
padding:14px 16px;margin:0 0 12px;font-size:13.5px;line-height:1.6;white-space:pre-wrap}
|
||||
.promo-block .btn{margin-top:10px}
|
||||
/* back-office polish: pane transitions, quick actions */
|
||||
@media(prefers-reduced-motion:no-preference){
|
||||
.pane:not([hidden]){animation:panein .25s ease}
|
||||
@keyframes panein{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
|
||||
}
|
||||
.qa{display:flex;flex-direction:column;gap:8px}
|
||||
.qa button{display:flex;align-items:center;gap:10px;background:rgba(67,232,195,.05);border:1px solid var(--line);
|
||||
color:var(--ink);font:600 14px var(--disp);padding:11px 14px;border-radius:11px;cursor:pointer;text-align:left;
|
||||
transition:border-color .15s ease,background .15s ease}
|
||||
.qa button:hover{border-color:var(--mint);background:rgba(67,232,195,.1)}
|
||||
/* member sub-navigation */
|
||||
.subnav{display:flex;gap:6px;flex-wrap:wrap;background:rgba(16,28,24,.7);border:1px solid var(--line);
|
||||
border-radius:999px;padding:6px 8px;width:fit-content;margin:0 0 22px}
|
||||
.subnav button{background:transparent;border:0;color:var(--muted);font:600 13.5px var(--disp);
|
||||
padding:8px 16px;border-radius:999px;cursor:pointer}
|
||||
.subnav button:hover{color:var(--ink)}
|
||||
.subnav button.on{color:var(--mint-ink);background:var(--mint)}
|
||||
/* member dashboard: qualification progress + roster */
|
||||
.qualbar{position:relative;height:12px;border-radius:999px;background:rgba(4,8,7,.7);border:1px solid var(--line-strong);
|
||||
margin:26px 0 30px;max-width:520px}
|
||||
.qualbar #qualFill{height:100%;border-radius:999px;background:linear-gradient(90deg,var(--mint),var(--mint-hi));
|
||||
box-shadow:0 0 18px rgba(67,232,195,.4);width:0;transition:width .6s ease}
|
||||
.qb-mark{position:absolute;top:16px;transform:translateX(-50%);font-family:var(--mono);font-size:11px;color:var(--muted);white-space:nowrap}
|
||||
.qb-mark.end{left:auto!important;right:0;transform:none} /* keeps the last label inside the card on mobile */
|
||||
#inviteLine,#pitchPreview{overflow-wrap:anywhere}
|
||||
img{max-width:100%}
|
||||
.roster{width:100%;border-collapse:collapse;font-size:13.5px}
|
||||
.roster td{padding:8px 10px;border-bottom:1px solid var(--line)}
|
||||
.roster td:first-child{font-family:var(--mono)}
|
||||
.roster tr:last-child td{border-bottom:0}
|
||||
/* chat widget */
|
||||
#iapChatBtn{position:fixed;right:20px;bottom:20px;z-index:40;width:56px;height:56px;border-radius:50%;
|
||||
border:0;background:var(--mint);color:var(--mint-ink);font-size:24px;cursor:pointer;
|
||||
box-shadow:0 8px 30px rgba(67,232,195,.4)}
|
||||
#iapChatBtn:hover{transform:scale(1.06)}
|
||||
#iapChatPanel{position:fixed;right:20px;bottom:88px;z-index:40;width:min(360px,calc(100vw - 40px));
|
||||
background:var(--panel-solid);border:1px solid var(--line-strong);border-radius:18px;overflow:hidden;
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.65);display:flex;flex-direction:column}
|
||||
.ch-head{padding:14px 16px;border-bottom:1px solid var(--line);font-family:var(--disp);position:relative}
|
||||
.ch-head .ch-sub{display:block;font-size:12px;color:var(--muted);font-family:"Segoe UI",system-ui,sans-serif}
|
||||
#iapChatClose{position:absolute;right:10px;top:10px;background:transparent;border:0;color:var(--muted);
|
||||
font-size:22px;cursor:pointer;line-height:1}
|
||||
.ch-msgs{padding:14px;overflow-y:auto;max-height:340px;display:flex;flex-direction:column;gap:10px}
|
||||
.ch-m{border-radius:12px;padding:9px 13px;font-size:13.5px;line-height:1.5;max-width:86%}
|
||||
.ch-m.bot{background:rgba(67,232,195,.08);border:1px solid rgba(67,232,195,.2);align-self:flex-start}
|
||||
.ch-m.me{background:rgba(130,148,196,.12);border:1px solid var(--line);align-self:flex-end}
|
||||
.ch-m a{word-break:break-all}
|
||||
.ch-input{display:flex;gap:8px;padding:11px;border-top:1px solid var(--line)}
|
||||
.ch-input input{flex:1;min-width:0}
|
||||
.ch-input button{background:var(--mint);color:var(--mint-ink);border:0;border-radius:10px;padding:0 16px;
|
||||
font-weight:700;cursor:pointer;font-family:var(--disp)}
|
||||
@media(prefers-reduced-motion:no-preference){
|
||||
.feed .row:first-child{animation:landed .9s ease}
|
||||
@keyframes landed{from{background:rgba(67,232,195,.3)}to{background:var(--mint-soft)}}
|
||||
}
|
||||
|
||||
/* ── Sponsor chat: FAB, slide-in drawer, threads, bubbles, presence ── */
|
||||
.bo-chat{display:flex;align-items:center}
|
||||
.bo-chat .pill{margin-left:auto;min-width:20px;height:20px;padding:0 6px;border-radius:10px;background:var(--mint);
|
||||
color:var(--mint-ink);font:700 12px/20px var(--disp);text-align:center}
|
||||
.chat-drawer{position:fixed;right:0;top:0;bottom:0;width:380px;max-width:92vw;z-index:70;display:flex;flex-direction:column;
|
||||
background:var(--panel-solid);border-left:1px solid var(--line-strong);box-shadow:-16px 0 40px rgba(0,0,0,.45);
|
||||
transform:translateX(0);animation:chatIn .16s ease}
|
||||
@keyframes chatIn{from{transform:translateX(24px);opacity:.4}to{transform:translateX(0);opacity:1}}
|
||||
@media (prefers-reduced-motion:reduce){.chat-drawer{animation:none}.chat-fab{transition:none}}
|
||||
.chat-head{display:flex;align-items:center;gap:8px;padding:12px 12px;border-bottom:1px solid var(--line)}
|
||||
.chat-who{display:flex;flex-direction:column;line-height:1.15;min-width:0;flex:1}
|
||||
.chat-who b{font:700 15px var(--disp);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.chat-icon{background:none;border:0;color:var(--muted);font-size:26px;line-height:1;cursor:pointer;padding:0 6px}
|
||||
.chat-icon:hover{color:var(--ink)}
|
||||
.pres-dot{width:9px;height:9px;border-radius:50%;background:var(--muted);flex:0 0 auto}
|
||||
.pres-dot.on{background:var(--mint);box-shadow:0 0 8px var(--mint)}
|
||||
.chat-threads{flex:1;overflow:auto}
|
||||
.chat-thread{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--line);cursor:pointer}
|
||||
.chat-thread:hover{background:var(--mint-soft)}
|
||||
.chat-thread .ct-main{flex:1;min-width:0}
|
||||
.chat-thread .ct-name{font:700 14px var(--disp)}
|
||||
.chat-thread .ct-last{color:var(--muted);font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.chat-thread .ct-un{min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:var(--mint);color:var(--mint-ink);
|
||||
font:700 12px/20px var(--disp);text-align:center}
|
||||
.chat-empty{padding:22px 16px;color:var(--muted);font-size:14px}
|
||||
.chat-convo{flex:1;display:flex;flex-direction:column;min-height:0}
|
||||
.chat-msgs{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:8px}
|
||||
.cbub{max-width:78%;padding:9px 12px;border-radius:14px;font-size:14px;line-height:1.4;white-space:pre-wrap;word-wrap:break-word}
|
||||
.cbub.them{align-self:flex-start;background:var(--panel);border:1px solid var(--line);border-bottom-left-radius:4px}
|
||||
.cbub.me{align-self:flex-end;background:var(--mint);color:var(--mint-ink);border-bottom-right-radius:4px}
|
||||
.cbub .ct-time{display:block;margin-top:4px;font-size:10.5px;opacity:.6}
|
||||
.chat-day{align-self:center;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.05em;margin:6px 0}
|
||||
.chat-banner{padding:8px 14px;border-top:1px solid var(--line);color:var(--muted);font-size:12.5px;background:var(--ground2)}
|
||||
.chat-compose{display:flex;gap:8px;padding:10px;border-top:1px solid var(--line);align-items:flex-end}
|
||||
.chat-compose textarea{flex:1;resize:vertical;min-height:44px;max-height:45vh;line-height:1.45;background:var(--ground2);border:1px solid var(--line);
|
||||
border-radius:12px;color:var(--ink);padding:9px 11px;font:400 14px var(--disp)}
|
||||
.chat-compose .btn{padding:9px 16px}
|
||||
.chat-msg-btn{margin-left:auto;font-size:12px;padding:3px 10px}
|
||||
.switch{display:flex;align-items:center;gap:10px;cursor:pointer;font-size:14px}
|
||||
.switch input{width:18px;height:18px;accent-color:var(--mint)}
|
||||
|
||||
/* ── Overview line tree ── */
|
||||
.line-tree{margin-top:6px}
|
||||
.lt-top{margin-left:26px}
|
||||
.lt-you{display:inline-block;background:var(--mint);color:var(--mint-ink);font-weight:800;padding:6px 18px;border-radius:999px;font-size:13px}
|
||||
.lt-stem{height:12px;border-left:2px solid var(--line-strong);width:0;margin:0 0 6px 30px}
|
||||
.lt-row{display:flex;align-items:center;gap:10px;margin:7px 0}
|
||||
.lt-cap{font:700 11px var(--mono);color:var(--muted);width:22px;flex:0 0 auto}
|
||||
.lt-nodes{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.lt-node{background:var(--panel);border:1px solid var(--line-strong);border-radius:10px;padding:6px 12px;font-size:13px;font-weight:600;white-space:nowrap}
|
||||
.lt-node.deep{color:var(--muted)}
|
||||
.lt-node.qualified{border-color:#ffb238;color:#ffd15c;box-shadow:0 0 0 1px rgba(255,177,56,.35)}
|
||||
.lt-node.open{border-style:dashed;color:var(--muted);background:transparent}
|
||||
.lt-node .lt-n{display:inline-block;font-style:normal;font-size:10px;line-height:16px;min-width:16px;text-align:center;border-radius:8px;background:rgba(255,177,56,.18);color:#ffd15c;margin-left:6px;padding:0 4px;font-variant-numeric:tabular-nums}
|
||||
|
||||
.sh-report{display:block;text-align:center;margin-top:10px;color:rgba(255,255,255,.45);font-size:12px}
|
||||
.sh-report:hover{color:rgba(255,255,255,.8)}
|
||||
|
||||
/* ── promo tools (2026-09-09) ── */
|
||||
.promo-strip{display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;padding:16px 20px}
|
||||
.angle-list{display:flex;flex-direction:column;gap:8px}
|
||||
.hp-field{position:absolute;left:-9999px;top:auto;width:1px;height:1px;opacity:0;overflow:hidden}
|
||||
.hbars{display:flex;align-items:flex-end;gap:2px;height:20px;margin-top:6px;width:120px}
|
||||
.hbars i{flex:1 1 0;background:var(--mint);opacity:.75;border-radius:1px 1px 0 0;min-width:2px}
|
||||
.hbars i:hover{opacity:1}
|
||||
.icon-check{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px}
|
||||
.icon-check .ic-btn{font-size:26px;line-height:1;padding:10px 14px;border-radius:12px;border:1px solid var(--line-strong);background:var(--panel);cursor:pointer}
|
||||
.icon-check .ic-btn:hover{border-color:var(--mint)}
|
||||
.angle-row.pb-acc>summary{padding:12px 14px;font-size:14.5px}
|
||||
.angle-row .angle-body{display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;padding:0 14px 14px}
|
||||
.angle-row .angle-txt{flex:1 1 320px;min-width:0}
|
||||
.angle-name{font-family:var(--disp);font-weight:700;font-size:15px}
|
||||
.angle-hook{color:var(--mint);font-size:13px;font-weight:500}
|
||||
.angle-tag{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);border:1px solid var(--line-strong);border-radius:999px;padding:1px 7px}
|
||||
.angle-url{overflow-wrap:anywhere;margin-top:4px;display:block;color:var(--mint);text-decoration:none}
|
||||
.angle-url:hover{text-decoration:underline}
|
||||
.angle-btns{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.angle-btns .btn{margin-top:0}
|
||||
.promo-pills{margin:4px 0 14px}
|
||||
.promo-block .pb-head{display:flex;gap:8px;align-items:center;margin-bottom:8px;font-size:13.5px}
|
||||
.promo-block .pb-net{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mint)}
|
||||
.promo-block .pb-text{white-space:pre-wrap;font-size:14.5px;line-height:1.5}
|
||||
.promo-block .pb-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||
.promo-block .pb-actions .btn{margin-top:0}
|
||||
.promo-bv{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:16px;padding:14px 16px;border:1px dashed var(--line-strong);border-radius:12px}
|
||||
.promo-bv b{font-family:var(--disp)}
|
||||
.obj-wrap{display:flex;flex-direction:column;gap:8px}
|
||||
.obj{border:1px solid var(--line);border-radius:12px;background:rgba(4,8,7,.4)}
|
||||
.obj summary{cursor:pointer;padding:12px 16px;font-family:var(--disp);font-weight:700;font-size:15px;list-style:none;display:flex;justify-content:space-between;gap:10px}
|
||||
.obj summary::after{content:"+";color:var(--mint);font-weight:800}
|
||||
.obj[open] summary::after{content:"–"}
|
||||
.obj summary::-webkit-details-marker{display:none}
|
||||
.obj summary::marker{content:""}
|
||||
.obj summary{text-align:left}
|
||||
.obj-body{padding:0 16px 14px;display:grid;gap:12px}
|
||||
.obj .lab{display:inline-block;font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--mint);margin-bottom:4px}
|
||||
.obj .lab-say{color:var(--amber)}
|
||||
.obj-truth p{margin:0;font-size:14.5px;color:var(--ink)}
|
||||
.obj-say{margin:0}
|
||||
|
||||
.lin-row .earn{font-family:var(--mono);font-size:12px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||
.lin-row .earn.on{color:var(--mint);font-weight:700}
|
||||
|
||||
.wo-slot{border:1px solid var(--line);border-radius:12px;padding:12px 14px}
|
||||
.wo-slot.locked{opacity:.6}
|
||||
.wo-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
|
||||
.wo-slot img{max-width:100%;border-radius:8px}
|
||||
|
||||
/* getting-started stepper on the Overview (Jim could not find the wallet step, 2026-09-14) */
|
||||
.gs{border-color:rgba(67,232,195,.45)}
|
||||
.gs-steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;margin:4px 0 12px}
|
||||
.gs-step{display:flex;gap:10px;align-items:center;border:1px solid var(--line);border-radius:12px;padding:10px 12px;color:var(--muted);font-size:14px}
|
||||
.gs-step .n{flex:0 0 26px;width:26px;height:26px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-weight:700;border:1px solid var(--line);font-size:13px}
|
||||
.gs-step.done{color:var(--ink)} .gs-step.done .n{background:var(--mint);color:var(--mint-ink);border-color:var(--mint)}
|
||||
.gs-step.now{color:var(--ink);border-color:var(--mint);background:rgba(67,232,195,.07)} .gs-step.now .n{border-color:var(--mint);color:var(--mint)}
|
||||
.gs-now{display:flex;gap:14px;align-items:center;flex-wrap:wrap;border-top:1px solid var(--line);padding-top:12px}
|
||||
.gs-now p{margin:0;flex:1;min-width:240px;max-width:60ch} .gs-now p b{display:block;font-family:var(--disp);font-size:16px;margin-bottom:2px}
|
||||
@keyframes gsPulse{0%{box-shadow:0 0 0 0 rgba(67,232,195,.7)}70%{box-shadow:0 0 0 14px rgba(67,232,195,0)}100%{box-shadow:0 0 0 0 rgba(67,232,195,0)}}
|
||||
.pulse{animation:gsPulse 1.2s ease-out 4}
|
||||
|
||||
/* promo toolkit tiers */
|
||||
.tk-tiers{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px}
|
||||
.tk-tier{border:1px solid var(--line);border-radius:12px;padding:12px 14px;font-size:13.5px;color:var(--muted)}
|
||||
.tk-tier.reached{border-color:rgba(67,232,195,.5);color:var(--ink)} .tk-tier.current{background:rgba(67,232,195,.07)}
|
||||
.tk-tier b{display:block;font-family:var(--disp);font-size:15px;color:var(--ink)} .tk-tier .need{font-size:12px;margin:2px 0 8px;color:var(--muted)}
|
||||
.tk-tier ul{margin:0;padding-left:16px} .tk-tier li{margin:3px 0} .tk-tier li.soon{opacity:.65} .tk-tier li.soon::after{content:' (coming)';font-size:11px;color:var(--muted)}
|
||||
.tk-tier .lock{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#ffd15c}
|
||||
.tk-hist{border-top:1px solid var(--line);padding:8px 0;font-size:13px} .tk-hist .muted{font-size:12px}
|
||||
.tk-sec{margin-top:18px;padding-top:14px;border-top:1px solid var(--line)} .tk-grid{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.tk-vids{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px} .tk-vid{border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:13px} .tk-vid b{display:block;font-weight:600;margin-bottom:4px} .tk-vid .st{font-size:12px;color:var(--muted)}
|
||||
.tk-bar{height:8px;border-radius:99px;background:rgba(255,255,255,.08);overflow:hidden;margin:6px 0 4px} .tk-bar i{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,#1fb894,#43e8c3);transition:width .6s ease;min-width:4px}
|
||||
.tk-table{width:100%;border-collapse:collapse;font-size:13px} .tk-table th,.tk-table td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)} .tk-table th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)} .tk-table td.n{font-variant-numeric:tabular-nums;text-align:right} .tk-table th.n{text-align:right} .tk-table tr.best td{color:var(--mint)}
|
||||
.tk-team{border:1px solid var(--line);border-radius:10px;padding:8px 12px;margin:6px 0;font-size:13px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;justify-content:space-between} .tk-team .who b{font-weight:600} .tk-team .who span{color:var(--muted);font-size:12px} .tk-team.stalled{border-color:rgba(255,209,92,.45)}
|
||||
.tk-nudge{width:100%;margin:8px 0 0;padding:8px 12px;border:1px dashed var(--line);border-radius:10px;font-size:13px}
|
||||
/* click sources stack one per line so the Clicks column stays narrow (Marty, 2026-09-15) */
|
||||
.clk-src span{display:block;white-space:nowrap} .camp-table td{white-space:normal} .camp-table td:first-child{max-width:260px}
|
||||
/* campaigns list on phones and tablets: each row becomes a card, buttons at the bottom, no sideways scroll (Jim, 2026-09-15) */
|
||||
@media (max-width:900px){
|
||||
.tablewrap:has(.camp-table){overflow:visible}
|
||||
.camp-table{display:block;width:100%;border-collapse:separate}
|
||||
.camp-table thead{display:none}
|
||||
.camp-table tbody{display:block}
|
||||
.camp-table tr{display:block;border:1px solid var(--line);border-radius:12px;padding:10px 12px 12px;margin:0 0 10px;background:var(--panel)}
|
||||
.camp-table td{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:5px 0;border:0;text-align:left;max-width:100%;overflow:hidden}
|
||||
.camp-table td.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.camp-table td::before{content:attr(data-l);color:var(--muted);font-size:11px;letter-spacing:.06em;text-transform:uppercase;flex:0 0 auto}
|
||||
.camp-table td:first-child{display:block;padding:0 0 6px;font-size:16px} .camp-table td:first-child::before{display:none}
|
||||
.camp-table td:first-child > *{max-width:100%}
|
||||
.camp-table td.act{display:flex;flex-wrap:wrap;justify-content:flex-start;gap:8px;margin-top:8px;padding-top:10px;border-top:1px solid var(--line)} .camp-table td.act::before{display:none}
|
||||
.camp-table td.act .btn{flex:1 1 auto;text-align:center}
|
||||
}
|
||||
|
||||
/* Pipeline board (Marty, 2026-09-15): columns scroll sideways inside their own box, never the page */
|
||||
.pipe-board{display:flex;gap:10px;overflow-x:auto;padding:4px 2px 10px;scroll-snap-type:x proximity}
|
||||
.pipe-col{flex:0 0 220px;scroll-snap-align:start;background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:12px;padding:10px;min-height:120px}
|
||||
.pipe-col h4{margin:0 0 2px;font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--mint);display:flex;justify-content:space-between;gap:8px}
|
||||
.pipe-col h4 span{color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
.pipe-col .hint{font-size:11.5px;color:var(--muted);margin:0 0 8px;line-height:1.35}
|
||||
.pipe-card{background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:10px;padding:8px 10px;margin:0 0 8px;cursor:pointer;transition:border-color .15s}
|
||||
.pipe-card:hover,.pipe-card:focus-visible{border-color:var(--mint);outline:none}
|
||||
.pipe-card.on{border-color:var(--gold,#e6c15a)}
|
||||
.pipe-card .nm{font-weight:700;font-size:14px;display:flex;justify-content:space-between;gap:6px;align-items:baseline}
|
||||
.pipe-card .nm small{font-weight:400;font-size:11px;color:var(--muted);white-space:nowrap}
|
||||
.pipe-card .sub{font-size:12px;color:var(--muted);margin-top:3px;line-height:1.35}
|
||||
.pipe-card .note{font-size:12px;color:var(--ink);margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.pipe-card .chip{margin-top:5px;margin-right:4px}
|
||||
.pipe-due{display:grid;gap:6px}
|
||||
.pipe-due .pipe-card{margin:0}
|
||||
@media (max-width:640px){.pipe-form{grid-template-columns:1fr !important}.pipe-col{flex-basis:200px}}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Transaction viewer: renders one tx from /api/tx/<hash> (the server-side RPC
|
||||
// relay, so the browser never talks to the chain directly and CSP stays 'self').
|
||||
// This is the "verify it yourself" page for chains without a public explorer;
|
||||
// when the config carries an explorer URL, feed links point there instead.
|
||||
(async function () {
|
||||
await IAP.renderNav('ledger');
|
||||
const c = await IAP.getConfig();
|
||||
const hash = location.pathname.split('/').pop();
|
||||
IAP.$('txHash').textContent = hash;
|
||||
IAP.$('txChain').textContent = 'on ' + (c.chainName || 'the settlement chain');
|
||||
let r = null;
|
||||
try { r = await (await fetch('/api/tx/' + hash)).json(); } catch (e) {}
|
||||
const stat = IAP.$('txStatus');
|
||||
if (!r || !r.found) {
|
||||
stat.textContent = 'not found';
|
||||
stat.className = 'badge amber';
|
||||
IAP.$('txHint').hidden = false;
|
||||
return;
|
||||
}
|
||||
const ok = r.status === '0x1';
|
||||
stat.textContent = ok ? '✓ confirmed' : '✗ reverted';
|
||||
stat.className = 'badge' + (ok ? '' : ' amber');
|
||||
const hx = v => { try { return parseInt(v, 16); } catch (e) { return 0; } };
|
||||
const rows = [
|
||||
['Block', '#' + hx(r.blockNumber).toLocaleString()],
|
||||
['Time', r.ts ? new Date(hx(r.ts) * 1000).toLocaleString() : 'pending'],
|
||||
['From', r.from || ''],
|
||||
['To (contract)', r.to || ''],
|
||||
['Value sent', IAP.fmtPol(r.valueWei || '0') + ' POL'],
|
||||
['Gas used', hx(r.gasUsed).toLocaleString()]
|
||||
];
|
||||
const tb = IAP.$('txTable');
|
||||
tb.hidden = false;
|
||||
tb.querySelector('tbody').innerHTML = rows.map(x =>
|
||||
'<tr><td class="muted small" style="white-space:nowrap">' + x[0] + '</td>'
|
||||
+ '<td class="mono small" style="overflow-wrap:anywhere">' + String(x[1]).replace(/[&<>]/g, '') + '</td></tr>').join('');
|
||||
if (r.events && r.events.length) {
|
||||
IAP.$('evCard').hidden = false;
|
||||
const feed = IAP.$('evFeed');
|
||||
for (const ev of r.events) feed.appendChild(IAP.feedRow(ev, c));
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,122 @@
|
||||
// Full-screen ad viewer: the advertiser URL fills the tab, a countdown runs in
|
||||
// the top bar (paused whenever this tab loses focus), and once the dwell is
|
||||
// done the server hands out a human check. Solve it and the view credits.
|
||||
// The dwell floor is enforced on the SERVER clock; this UI cannot cheat it.
|
||||
(() => {
|
||||
const $ = id => document.getElementById(id);
|
||||
const token = location.pathname.split('/').pop();
|
||||
const framed = window.self !== window.top; // shown inside the dashboard's in-page ad overlay
|
||||
let left = 5, timer = null, credited = false, asking = false;
|
||||
const setMsg = t => { $('vMsg').textContent = t; };
|
||||
async function j(url, body) {
|
||||
const r = await fetch(url, body
|
||||
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
||||
: undefined);
|
||||
return r.json();
|
||||
}
|
||||
function tick() {
|
||||
if (credited || asking) return;
|
||||
// In the in-page overlay the iframe often doesn't hold focus even though it's
|
||||
// fully on screen, so gate on tab visibility only when framed.
|
||||
if (document.visibilityState !== 'visible' || (!framed && !document.hasFocus())) {
|
||||
$('vTimer').classList.add('paused');
|
||||
$('vTimer').textContent = 'paused';
|
||||
setMsg(framed ? 'Keep this ad on screen to finish the countdown.' : 'Come back to this tab to keep the countdown moving.');
|
||||
return;
|
||||
}
|
||||
$('vTimer').classList.remove('paused');
|
||||
left = Math.max(0, left - 0.25);
|
||||
$('vTimer').textContent = Math.ceil(left) + 's left';
|
||||
if (left <= 0) { clearInterval(timer); askChallenge(); }
|
||||
}
|
||||
async function askChallenge() {
|
||||
asking = true;
|
||||
$('vTimer').textContent = 'check';
|
||||
$('vTimer').classList.add('done');
|
||||
setMsg('One quick check to count the view:');
|
||||
let c = await j('/api/my/viewchallenge?token=' + token);
|
||||
if (c.early) { // server clock says not quite yet: wait it out and re-ask
|
||||
await new Promise(r => setTimeout(r, (c.wait || 1) * 1000 + 300));
|
||||
c = await j('/api/my/viewchallenge?token=' + token);
|
||||
}
|
||||
if (c.error) return fail(c.error);
|
||||
renderChallenge(c);
|
||||
}
|
||||
function renderChallenge(c) {
|
||||
$('vPrompt').textContent = 'Click the ' + c.prompt + ':';
|
||||
const w = $('vOpts');
|
||||
w.innerHTML = '';
|
||||
c.options.forEach((em, i) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = em;
|
||||
b.addEventListener('click', () => answer(i));
|
||||
w.appendChild(b);
|
||||
});
|
||||
$('vCheck').classList.add('on');
|
||||
}
|
||||
async function answer(i) {
|
||||
const r = await j('/api/my/adview', { token, answer: i });
|
||||
if (r.error) {
|
||||
if (r.retry) {
|
||||
setMsg('Not that one — try again.');
|
||||
const c = await j('/api/my/viewchallenge?token=' + token);
|
||||
if (!c.error) return renderChallenge(c);
|
||||
return fail(c.error);
|
||||
}
|
||||
return fail(r.error);
|
||||
}
|
||||
credited = true;
|
||||
$('vCheck').classList.remove('on');
|
||||
$('vTimer').textContent = '✓ credited';
|
||||
const st = r.status || r;
|
||||
setMsg('View ' + st.views + ' of ' + st.target + ' counted for today.'
|
||||
+ (st.views >= st.target && !st.claimed ? ' Head back and claim your credits.' : ''));
|
||||
$('vDone').classList.add('on');
|
||||
try { localStorage.setItem('iap-view-done', String(Date.now())); } catch (e) {}
|
||||
try { if (framed) window.parent.postMessage({ t: 'iap-view-done' }, location.origin); } catch (e) {}
|
||||
}
|
||||
function fail(msg) {
|
||||
credited = true; // stop the loop; this view is over either way
|
||||
if (timer) clearInterval(timer);
|
||||
$('vCheck').classList.remove('on');
|
||||
$('vTimer').textContent = '—';
|
||||
setMsg(msg);
|
||||
$('vDone').classList.add('on');
|
||||
}
|
||||
$('vClose').addEventListener('click', () => {
|
||||
window.close();
|
||||
// window.close() is blocked in mobile and in-app (wallet dApp) browsers. If
|
||||
// the tab is still here a moment later, take them back to the dashboard so
|
||||
// they are never stuck on a tab they can't close.
|
||||
setTimeout(() => {
|
||||
if (!window.closed) { setMsg('Taking you back to your dashboard…'); location.href = '/my#earn'; }
|
||||
}, 400);
|
||||
});
|
||||
// When shown inside the dashboard's in-page overlay there is no tab to close:
|
||||
// hide "Close tab" and turn "Back to dashboard" into a close-the-overlay signal.
|
||||
if (framed) {
|
||||
const cb = $('vClose'); if (cb) cb.style.display = 'none';
|
||||
const back = document.querySelector('#vDone a[href="/my#earn"]');
|
||||
if (back) back.addEventListener('click', e => { e.preventDefault(); try { window.parent.postMessage({ t: 'iap-view-close' }, location.origin); } catch (x) {} });
|
||||
}
|
||||
(async () => {
|
||||
const info = await j('/api/my/viewinfo?token=' + token);
|
||||
if (info.error) {
|
||||
$('vFrame').remove();
|
||||
const d = document.createElement('div');
|
||||
d.className = 'vfail';
|
||||
d.textContent = info.error;
|
||||
document.querySelector('.vw').appendChild(d);
|
||||
$('vDone').classList.add('on');
|
||||
$('vTimer').textContent = '—';
|
||||
setMsg('');
|
||||
return;
|
||||
}
|
||||
left = info.dwell || 5;
|
||||
$('vFrame').src = info.targetUrl;
|
||||
setMsg('Watching: ' + (info.adName || 'member ad') + ' — stay on this tab.');
|
||||
$('vTimer').textContent = left + 's left';
|
||||
timer = setInterval(tick, 250);
|
||||
})();
|
||||
})();
|
||||
@@ -0,0 +1,104 @@
|
||||
// Public banner wall: a member's line banner plus their upline ladder, with
|
||||
// their join link. The viral surface: members send traffic here, every visit
|
||||
// puts eyes on the whole line.
|
||||
(async function () {
|
||||
await IAP.renderNav('');
|
||||
const name = location.pathname.split('/').pop();
|
||||
let w = null;
|
||||
try { w = await (await fetch('/api/wall/' + encodeURIComponent(name))).json(); } catch (e) {}
|
||||
const title = IAP.$('wallTitle');
|
||||
const hostEl = IAP.$('wallHost');
|
||||
if (!w || w.error) {
|
||||
title.textContent = 'No wall under that name.';
|
||||
IAP.$('wallJoin').href = '/my';
|
||||
return;
|
||||
}
|
||||
const safeName = String(w.name).replace(/[&<>]/g, '');
|
||||
title.innerHTML = safeName;
|
||||
if (hostEl) hostEl.innerHTML = safeName;
|
||||
IAP.$('bioHead').hidden = false;
|
||||
if (w.avatarUrl) { const av = IAP.$('bioAvatar'); av.src = w.avatarUrl; av.hidden = false; }
|
||||
if (w.badge) { const bb = IAP.$('bioBadge'); if (bb) { bb.src = w.badge.img; bb.title = w.badge.label + ' badge'; bb.hidden = false; } }
|
||||
IAP.$('bioText').textContent = w.bio || 'Building a team on LinkSpin — join through this page and you\'re in my line.';
|
||||
if (w.qrUrl) IAP.$('bioQr').src = w.qrUrl;
|
||||
// social links
|
||||
const SOC = { facebook: 'Facebook', twitter: 'X', youtube: 'YouTube', instagram: 'Instagram', tiktok: 'TikTok', telegram: 'Telegram', linkedin: 'LinkedIn', website: 'Website' };
|
||||
// small line icons, one per platform (currentColor so they follow the pill color)
|
||||
const svg = inner => '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + inner + '</svg>';
|
||||
const ICON = {
|
||||
facebook: svg('<path d="M14 8h3V4h-3a4 4 0 0 0-4 4v2H7v4h3v7h4v-7h3l1-4h-4V8.5c0-.3.2-.5.5-.5z" fill="currentColor" stroke="none"/>'),
|
||||
twitter: svg('<path d="M4 4l16 16M20 4L4 20"/>'),
|
||||
youtube: svg('<rect x="2.5" y="6" width="19" height="12" rx="4"/><path d="M10 9.5v5l4.5-2.5z" fill="currentColor" stroke="none"/>'),
|
||||
instagram: svg('<rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.3" cy="6.7" r="1" fill="currentColor" stroke="none"/>'),
|
||||
tiktok: svg('<path d="M14 4c.3 2.3 1.9 3.8 4.3 4v3.1c-1.6 0-3.1-.5-4.3-1.4v5.8a5 5 0 1 1-5-5v3.2a1.9 1.9 0 1 0 1.9 1.9V4z" fill="currentColor" stroke="none"/>'),
|
||||
telegram: svg('<path d="M21 4L3 11.5l5.5 2L11 20l3-4.5L19 19z" fill="currentColor" stroke="none"/>'),
|
||||
linkedin: svg('<circle cx="6" cy="5.5" r="1.6" fill="currentColor" stroke="none"/><path d="M4.5 9.5h3V20h-3z" fill="currentColor" stroke="none"/><path d="M11 9.5h3v1.6c.7-1.1 1.9-1.9 3.5-1.9 2.8 0 3.5 1.9 3.5 4.5V20h-3v-5.6c0-1.3-.3-2.3-1.6-2.3s-2.4 1-2.4 2.4V20h-3z" fill="currentColor" stroke="none"/>'),
|
||||
website: svg('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3.5 3 14.5 0 18M12 3c-3 3.5-3 14.5 0 18"/>')
|
||||
};
|
||||
const sc = IAP.$('bioSocials');
|
||||
if (sc && w.socials && typeof w.socials === 'object') {
|
||||
const links = Object.keys(SOC).filter(k => w.socials[k]).map(k =>
|
||||
'<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;
|
||||
let viewed = {};
|
||||
try { viewed = JSON.parse(localStorage.getItem(VKEY) || '{}'); } catch (e) {}
|
||||
const DWELL = 10;
|
||||
const grid = IAP.$('wallGrid');
|
||||
IAP.adSlot('text', 'adSlotWallText'); IAP.adSlot('banner', 'adSlotWallBanner');
|
||||
grid.innerHTML = '';
|
||||
// join is gated: you must view every ad on the wall before you can join
|
||||
const joinBtn = IAP.$('wallJoin'), gate = IAP.$('wallGate');
|
||||
function updateGate() {
|
||||
const viewable = w.ladder.filter(m => m.targetUrl).length;
|
||||
const seen = w.ladder.filter((m, i) => m.targetUrl && viewed[i]).length;
|
||||
if (viewable > 0 && seen < viewable) {
|
||||
joinBtn.classList.add('disabled'); joinBtn.removeAttribute('href');
|
||||
if (gate) { gate.hidden = false; gate.textContent = 'View all ' + viewable + ' ads above to unlock joining (' + seen + '/' + viewable + ' viewed).'; }
|
||||
} else { joinBtn.href = w.joinUrl; joinBtn.classList.remove('disabled'); if (gate) gate.hidden = true; }
|
||||
}
|
||||
w.ladder.forEach((m, i) => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'wall-card';
|
||||
const safe = String(m.name || 'member').replace(/[&<>]/g, '');
|
||||
const creative = m.bannerUrl
|
||||
? '<img src="' + m.bannerUrl + '" alt="' + safe + ' banner">'
|
||||
: '<b>' + safe + '</b><br><span class="muted small">' + (m.targetUrl ? 'visit their site' : 'banner slot open') + '</span>';
|
||||
d.innerHTML = '<div class="wall-pos">Position ' + (i + 1) + (m.own ? ' · this wall' : m.admin ? ' · LinkSpin' : ' · their line') + '</div>'
|
||||
+ '<div class="wc-creative">' + creative + '</div>'
|
||||
+ '<div class="wc-action"></div>'
|
||||
+ '<div class="small muted" style="margin-top:8px">' + safe + '</div>';
|
||||
const act = d.querySelector('.wc-action');
|
||||
const done = () => { act.innerHTML = '<span class="wc-check">✓ viewed</span>'; };
|
||||
if (!m.targetUrl) { act.innerHTML = '<span class="muted small">no ad yet</span>'; }
|
||||
else if (viewed[i]) { done(); }
|
||||
else {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn small'; btn.textContent = 'View this ad';
|
||||
btn.addEventListener('click', () => {
|
||||
window.open(m.targetUrl, '_blank'); // real visit to the advertiser (no countdown)
|
||||
viewed[i] = 1;
|
||||
try { localStorage.setItem(VKEY, JSON.stringify(viewed)); } catch (e) {}
|
||||
done(); updateGate();
|
||||
});
|
||||
act.appendChild(btn);
|
||||
}
|
||||
grid.appendChild(d);
|
||||
});
|
||||
updateGate();
|
||||
})();
|
||||
@@ -0,0 +1,284 @@
|
||||
// Wallet plumbing via Reown AppKit — the universal connector every wallet is
|
||||
// built for (all wallets, QR + mobile deep-links, working icons). AppKit is
|
||||
// lazy-loaded from the CDN on first use; once connected we drive the raw
|
||||
// EIP-1193 provider for chain switch, SIWE sign-in, and contract transactions.
|
||||
window.IAPWallet = (function () {
|
||||
const SEL_BUY = '0xfd095e97'; // buy(uint32,uint32)
|
||||
const SEL_ACTIVATE = '0x1a93ec95'; // activate(uint32)
|
||||
const pad = v => BigInt(v).toString(16).padStart(64, '0');
|
||||
// Normalize a chainId to a decimal number. eth_chainId is meant to return a
|
||||
// hex string, but some wallets return a number or a decimal string — compare
|
||||
// numerically so a wallet's shape never crashes the flow.
|
||||
const chainNum = v => {
|
||||
if (v == null) return NaN;
|
||||
if (typeof v === 'number') return v;
|
||||
const s = String(v).trim();
|
||||
return /^0x/i.test(s) ? parseInt(s, 16) : parseInt(s, 10);
|
||||
};
|
||||
const APPKIT_URL = 'https://cdn.jsdelivr.net/npm/@reown/appkit-cdn@1.8.23/dist/appkit.js';
|
||||
|
||||
let modal = null, akPromise = null, provider = null;
|
||||
|
||||
async function initAppKit(c) {
|
||||
if (modal) return modal;
|
||||
if (akPromise) return akPromise;
|
||||
akPromise = (async () => {
|
||||
const mod = await import(APPKIT_URL);
|
||||
const { createAppKit, WagmiAdapter, networks } = mod;
|
||||
const netMap = { 80002: networks.polygonAmoy, 137: networks.polygon };
|
||||
const base = netMap[Number(c.chainId)] || networks.polygonAmoy;
|
||||
// Override the chain's RPC with our clean public endpoint. AppKit's built-in
|
||||
// networks advertise the WalletConnect RPC proxy
|
||||
// (rpc.walletconnect.org/v1/?chainId=…&projectId=…) as the chain RPC, and
|
||||
// wallets reject that query-string URL as "Invalid URL" when adding/switching
|
||||
// the network — the cause of Trust's "Invalid URL", MetaMask's switch loop,
|
||||
// and the failed mobile buy (the chain switch never completed).
|
||||
const rpc = String(c.rpc || '').trim();
|
||||
const net = rpc ? Object.assign({}, base, { rpcUrls: { default: { http: [rpc] }, public: { http: [rpc] } } }) : base;
|
||||
// Accept the wallet's usual networks too, so AppKit doesn't trap the user in
|
||||
// its own "Switch Network" modal — that modal loops on a testnet the wallet
|
||||
// can't auto-add. We switch to `net` ourselves (wallet_addEthereumChain adds
|
||||
// + switches in one step) and sendTx hard-guards the chain before signing.
|
||||
const allNets = [net];
|
||||
for (const k of ['polygon', 'mainnet']) {
|
||||
try { const n = networks[k]; if (n && n.id !== net.id) allNets.push(n); } catch (e) {}
|
||||
}
|
||||
const projectId = String(c.walletConnectProjectId || '').trim();
|
||||
const wagmiAdapter = new WagmiAdapter({ networks: allNets, projectId });
|
||||
modal = createAppKit({
|
||||
adapters: [wagmiAdapter], networks: allNets, projectId, defaultNetwork: net,
|
||||
metadata: { name: c.siteName || 'LinkSpin', description: c.tagline || 'Advertise and earn, paid on-chain.',
|
||||
url: location.origin, icons: [location.origin + '/logo-icon.png'] },
|
||||
features: { analytics: false, email: false, socials: [] },
|
||||
// Picker order: wallets without Trust's balance-proportion block go first.
|
||||
// Trust Wallet is NOT excluded; it just drops out of the featured row into
|
||||
// "All wallets" (Marty, 2026-09-09: move Trust to the bottom, not off).
|
||||
featuredWalletIds: [
|
||||
'c57ca95b47569778a828d19178114f4db188b89b763c899ba0be274e97267d96', // MetaMask
|
||||
'a797aa35c0fadbfc1a53e7f675162ed5226968b44a19ee3d24385c64d1d3c393', // Phantom
|
||||
'0b415a746fb9ee99cce155c2ceca0c6f6061b1dbca2d722b3ba16381d0562150', // SafePal
|
||||
'fd20dc426fb37566d803205b19bbc1d4096b248ac04548e3cfb6b3a38bd033aa' // Coinbase Wallet
|
||||
]
|
||||
});
|
||||
return modal;
|
||||
})();
|
||||
return akPromise;
|
||||
}
|
||||
|
||||
function currentAddress() { try { return (modal && modal.getAddress && modal.getAddress()) || null; } catch (e) { return null; } }
|
||||
|
||||
function waitForConnection(timeoutMs) {
|
||||
if (currentAddress()) return Promise.resolve(currentAddress());
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false, unsub = null;
|
||||
const finish = (addr, err) => { if (done) return; done = true; try { unsub && unsub(); } catch (e) {} err ? reject(err) : resolve(addr); };
|
||||
try { unsub = modal.subscribeAccount(acc => { if (acc && acc.isConnected && acc.address) finish(acc.address); }); } catch (e) {}
|
||||
const t0 = Date.now();
|
||||
(function poll() {
|
||||
if (done) return;
|
||||
const a = currentAddress();
|
||||
if (a) return finish(a);
|
||||
if (Date.now() - t0 > (timeoutMs || 180000)) return finish(null, new Error('Wallet connection timed out. Tap Connect and try again.'));
|
||||
setTimeout(poll, 400);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveProvider() {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
try { const p = modal.getWalletProvider ? await Promise.resolve(modal.getWalletProvider()) : null; if (p && p.request) return p; } catch (e) {}
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
throw new Error('Could not reach your wallet. Try connecting again.');
|
||||
}
|
||||
|
||||
function eth() { if (!provider) throw new Error('Connect your wallet first.'); return provider; }
|
||||
const isInjected = () => !!(provider && window.ethereum && (provider === window.ethereum || provider.isMetaMask));
|
||||
// the account the wallet will actually sign with: for an injected wallet (MetaMask
|
||||
// extension) that is its active account, which can differ from AppKit's cached one
|
||||
async function activeAddress(fallback) {
|
||||
if (isInjected()) { try { const a = await provider.request({ method: 'eth_accounts' }); if (a && a[0]) return a[0]; } catch (e) {} }
|
||||
return fallback || currentAddress();
|
||||
}
|
||||
// Force the wallet's own account picker. Injected wallets stay connected to the
|
||||
// site, so a plain disconnect/reconnect never shows one: asking for permissions
|
||||
// again makes MetaMask open its account-selection prompt, and whatever the user
|
||||
// ticks becomes the active account. WalletConnect wallets fall back to a fresh
|
||||
// session (the picker + the wallet app's own account choice).
|
||||
async function pickAccount() {
|
||||
const c = await IAP.getConfig();
|
||||
await initAppKit(c);
|
||||
const inj = window.ethereum;
|
||||
if (inj && inj.request) {
|
||||
try {
|
||||
await inj.request({ method: 'wallet_requestPermissions', params: [{ eth_accounts: {} }] });
|
||||
const accs = await inj.request({ method: 'eth_accounts' });
|
||||
if (accs && accs[0]) { provider = inj; await ensureChain(c).catch(() => {}); return accs[0]; }
|
||||
} catch (e) {
|
||||
if (e && (e.code === 4001 || /reject|denied/i.test(String(e.message || '')))) throw new Error('You closed the account picker. Pick the account you want and try again.');
|
||||
}
|
||||
}
|
||||
return freshConnect(c);
|
||||
}
|
||||
|
||||
// A WalletConnect session can die underneath AppKit's cached "connected"
|
||||
// state: the wallet app rejects or kills it (Trust does this after its own
|
||||
// security stop), the phone sleeps, the relay drops. AppKit still reports an
|
||||
// address, so the next request fails with a "disconnected" style error.
|
||||
// Detect that, wipe the stale session, and re-open the picker for a fresh one.
|
||||
const DEAD_RE = /disconnect|not connected|no matching key|session (topic|expired|deleted|not found)|call connect|please call connect|missing or invalid|relay/i;
|
||||
const isDead = e => DEAD_RE.test(String((e && e.message) || e || ''));
|
||||
async function freshConnect(c) {
|
||||
try { IAP.status('Your wallet session dropped. Reconnect in the picker…'); } catch (e) {}
|
||||
await disconnect();
|
||||
try { await modal.open(); } catch (e) {}
|
||||
const addr = await waitForConnection(180000);
|
||||
try { if (modal && modal.close) await modal.close(); } catch (e) {}
|
||||
provider = await resolveProvider();
|
||||
await ensureChain(c).catch(() => {});
|
||||
return addr;
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const c = await IAP.getConfig();
|
||||
await initAppKit(c);
|
||||
let addr = currentAddress();
|
||||
if (!addr) {
|
||||
// give AppKit a moment to rehydrate an existing session before popping the
|
||||
// picker — otherwise an already-connected wallet still gets the modal
|
||||
for (let i = 0; i < 8 && !addr; i++) { await new Promise(r => setTimeout(r, 150)); addr = currentAddress(); }
|
||||
}
|
||||
if (!addr) { try { await modal.open(); } catch (e) {} addr = await waitForConnection(180000); }
|
||||
try { if (modal && modal.close) await modal.close(); } catch (e) {} // dismiss the picker once we're connected
|
||||
provider = await resolveProvider();
|
||||
// probe the session: a dead WalletConnect session answers with a disconnect error
|
||||
try { await provider.request({ method: 'eth_chainId' }); }
|
||||
catch (e) { if (isDead(e)) return freshConnect(c); }
|
||||
await ensureChain(c).catch(() => {}); // AppKit already connects on the right network; switch is best-effort
|
||||
return addr;
|
||||
}
|
||||
|
||||
async function ensureChain(c) {
|
||||
const want = '0x' + Number(c.chainId).toString(16);
|
||||
let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) { return; }
|
||||
if (chainNum(cur) === Number(c.chainId)) return;
|
||||
try {
|
||||
await eth().request({ method: 'wallet_switchEthereumChain', params: [{ chainId: want }] });
|
||||
} catch (e) {
|
||||
// any failure (not just 4902): try to add the chain — wallet_addEthereumChain
|
||||
// adds AND switches in one step, which is what unblocks wallets that can't
|
||||
// otherwise reach a chain they don't already have (e.g. a testnet).
|
||||
const addParams = { chainId: want, chainName: c.chainName, nativeCurrency: { name: 'POL', symbol: 'POL', decimals: 18 }, rpcUrls: [c.rpc] };
|
||||
if (c.explorer && /^https?:\/\//i.test(c.explorer)) addParams.blockExplorerUrls = [c.explorer];
|
||||
try { await eth().request({ method: 'wallet_addEthereumChain', params: [addParams] }); } catch (e2) {}
|
||||
}
|
||||
}
|
||||
|
||||
// SIWE: challenge -> personal_sign -> verify (server sets the session cookie)
|
||||
async function signIn(opts) {
|
||||
const addr = (opts && opts.pick) ? await pickAccount() : await activeAddress(await connect());
|
||||
const ch = await (await fetch('/api/auth/challenge', { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr }) })).json();
|
||||
if (ch.error) throw new Error(ch.error);
|
||||
// hex-encode the message (Trust and others require hex for personal_sign)
|
||||
const hexMsg = '0x' + Array.from(new TextEncoder().encode(ch.message)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
const sig = await eth().request({ method: 'personal_sign', params: [hexMsg, addr] });
|
||||
const r = await (await fetch('/api/auth/verify', { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr, signature: sig, asPosition: !!(opts && opts.asPosition) }) })).json();
|
||||
if (r.error) throw new Error(r.error);
|
||||
return r;
|
||||
}
|
||||
|
||||
async function sendTx(data, valueWei) {
|
||||
const c = await IAP.getConfig();
|
||||
const addr = await connect();
|
||||
// Hard chain guard: connect()'s switch is best-effort and some wallets (or
|
||||
// AppKit's own modal) don't complete it. Never sign on the wrong chain —
|
||||
// a value tx to a contract that doesn't exist on that chain would look like
|
||||
// it "succeeded" while doing nothing.
|
||||
const wantNum = Number(c.chainId);
|
||||
let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {}
|
||||
if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum) {
|
||||
await ensureChain(c);
|
||||
try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {}
|
||||
if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum)
|
||||
throw new Error('Your wallet is on the wrong network. Switch it to ' + (c.chainName || 'the correct network') + ', then try again.');
|
||||
}
|
||||
const tx = { from: await activeAddress(addr), to: c.contract, data };
|
||||
if (valueWei) tx.value = '0x' + BigInt(valueWei).toString(16);
|
||||
// Amoy's Bor nodes enforce a ~25 gwei minimum priority fee that MetaMask's
|
||||
// own estimate misses ("gas tip below minimum"). Pull the network's correct
|
||||
// fees from the server and set them so the tx clears the floor.
|
||||
try {
|
||||
const g = await (await fetch('/api/gas')).json();
|
||||
if (g && g.maxPriorityFeePerGas && g.maxFeePerGas) {
|
||||
tx.maxPriorityFeePerGas = g.maxPriorityFeePerGas;
|
||||
tx.maxFeePerGas = g.maxFeePerGas;
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
return await eth().request({ method: 'eth_sendTransaction', params: [tx] });
|
||||
} catch (e) {
|
||||
if (!isDead(e)) throw e;
|
||||
// session died between connect and send: reconnect once and resend
|
||||
tx.from = await freshConnect(c);
|
||||
return eth().request({ method: 'eth_sendTransaction', params: [tx] });
|
||||
}
|
||||
}
|
||||
|
||||
// pay it forward: send POL straight from the sponsor's wallet to a downline member's
|
||||
// linked address. A native transfer, no contract, no site custody: the wallet app
|
||||
// shows the prefilled recipient and amount and the sponsor confirms there.
|
||||
async function sendPol(toAddress, valueWei) {
|
||||
if (!/^0x[0-9a-fA-F]{40}$/.test(String(toAddress || ''))) throw new Error('That member has no wallet address on file yet.');
|
||||
const c = await IAP.getConfig();
|
||||
const addr = await connect();
|
||||
const wantNum = chainNum(c.chainId);
|
||||
let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {}
|
||||
if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum) { await ensureChain(c); }
|
||||
const tx = { from: await activeAddress(addr), to: toAddress, value: '0x' + BigInt(valueWei).toString(16) };
|
||||
try { const g = await (await fetch('/api/gas')).json(); if (g && g.maxPriorityFeePerGas && g.maxFeePerGas) { tx.maxPriorityFeePerGas = g.maxPriorityFeePerGas; tx.maxFeePerGas = g.maxFeePerGas; } } catch (e) {}
|
||||
try { return await eth().request({ method: 'eth_sendTransaction', params: [tx] }); }
|
||||
catch (e) { if (!isDead(e)) throw e; tx.from = await freshConnect(c); return eth().request({ method: 'eth_sendTransaction', params: [tx] }); }
|
||||
}
|
||||
|
||||
async function waitTx(hash) {
|
||||
for (let i = 0; i < 90; i++) {
|
||||
try {
|
||||
const r = await (await fetch('/api/tx/' + hash)).json();
|
||||
if (r.found) return { status: r.status, blockNumber: r.blockNumber };
|
||||
} catch (e) { /* transient fetch failure (e.g. mobile app-switch) — keep polling */ }
|
||||
await new Promise(res => setTimeout(res, 2500));
|
||||
}
|
||||
throw new Error('Timed out waiting for the transaction. Check the explorer.');
|
||||
}
|
||||
|
||||
async function buy(productId, sponsorId, costWei) {
|
||||
const value = BigInt(costWei) + BigInt(costWei) / 50n; // 2% oracle-drift pad; contract refunds excess
|
||||
const data = SEL_BUY + pad(productId) + pad(sponsorId || 0);
|
||||
const hash = await sendTx(data, value);
|
||||
return { hash, receipt: await waitTx(hash) };
|
||||
}
|
||||
async function activate(sponsorId) {
|
||||
const data = SEL_ACTIVATE + pad(sponsorId || 0);
|
||||
const hash = await sendTx(data, null);
|
||||
return { hash, receipt: await waitTx(hash) };
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
// AppKit's disconnect can hang on the WalletConnect relay (esp. mobile) —
|
||||
// never block on it, so the UI can't get stuck "disconnecting".
|
||||
try { if (modal && modal.disconnect) await Promise.race([modal.disconnect(), new Promise(r => setTimeout(r, 1200))]); } catch (e) {}
|
||||
provider = null;
|
||||
try { Object.keys(localStorage).forEach(k => { if (/wc@2|walletconnect|w3m|wcm|reown|wagmi|appkit/i.test(k)) localStorage.removeItem(k); }); } catch (e) {}
|
||||
}
|
||||
|
||||
// native balance of the connected wallet (pre-flight check before a buy)
|
||||
async function balance(addr) {
|
||||
await connect();
|
||||
const h = await eth().request({ method: 'eth_getBalance', params: [addr || currentAddress(), 'latest'] });
|
||||
return BigInt(h);
|
||||
}
|
||||
function walletName() { try { const w = modal && modal.getWalletInfo && modal.getWalletInfo(); return (w && w.name) || ''; } catch (e) { return ''; } }
|
||||
return { connect, signIn, buy, activate, sendPol, waitTx, disconnect, balance, address: currentAddress, activeAddress, pickAccount, walletName };
|
||||
})();
|
||||
@@ -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'); }
|
||||
});
|
||||
})();
|
||||
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 408 KiB |
|
After Width: | Height: | Size: 626 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="50" viewBox="0 0 320 50" role="img" aria-label="InstantAdPay — Earn Instantly">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#041109"/>
|
||||
<stop offset="1" stop-color="#0b2018"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="node" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#8ffbe3"/>
|
||||
<stop offset="0.5" stop-color="#43e8c3"/>
|
||||
<stop offset="1" stop-color="#43e8c3" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="320" height="50" rx="6" fill="url(#bg)"/>
|
||||
<!-- circuit accents on the right -->
|
||||
<g stroke="#43e8c3" stroke-opacity="0.5" stroke-width="1" fill="none">
|
||||
<path d="M300 12 H274 M300 25 H262 M300 38 H278"/>
|
||||
<circle cx="274" cy="12" r="1.6" fill="#43e8c3" stroke="none"/>
|
||||
<circle cx="278" cy="38" r="1.6" fill="#43e8c3" stroke="none"/>
|
||||
</g>
|
||||
<circle cx="300" cy="25" r="16" fill="url(#node)"/>
|
||||
<text x="14" y="32" font-family="Sora, 'Segoe UI', system-ui, sans-serif" font-size="20" font-weight="800" fill="#eef7f3">Earn <tspan fill="#43e8c3">Instantly</tspan></text>
|
||||
<!-- Join Free pill -->
|
||||
<rect x="212" y="15" width="60" height="20" rx="10" fill="#43e8c3"/>
|
||||
<text x="242" y="29" text-anchor="middle" font-family="Sora, 'Segoe UI', system-ui, sans-serif" font-size="11" font-weight="800" fill="#03211a">Join Free</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 82 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: 782 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 |
@@ -0,0 +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 | LinkSpin</title>
|
||||
<meta name="description" content="Plain-language review of the LinkSpin 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://linkspin-test.saasy.top/contract">
|
||||
<meta property="og:type" content="website"><meta property="og:site_name" content="LinkSpin">
|
||||
<meta property="og:url" content="https://linkspin-test.saasy.top/contract">
|
||||
<meta property="og:title" content="LinkSpin — 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://linkspin-test.saasy.top/banners/iap-hero-1200x630.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="LinkSpin — 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://linkspin-test.saasy.top/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>LinkSpin · <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=20260914a"></script>
|
||||
<script src="/assets/contract.js?v=20260908p"></script>
|
||||
<script src="/assets/chat.js?v=20260906m"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Disclaimer | LinkSpin</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">LinkSpin is an advertising service with a referral program. It is not an investment, a security, or a passive-income scheme. We do not promise, project, or guarantee any earnings. Any figures shown in examples or sample creatives are illustrations only, not typical or expected results. Most participants should expect to earn little or nothing unless real advertising is purchased in their line.</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. LinkSpin 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=20260914a"></script>
|
||||
<script src="/assets/legal.js?v=20260908a"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>How earning works | LinkSpin</title>
|
||||
<meta name="description" content="How members earn ad credits on LinkSpin: the daily set, the claim streak, verified visits, videos, inbox ads, the login bonus, and what to do with the credits.">
|
||||
<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>and what to do with it</em>.</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 LinkSpin 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>
|
||||
|
||||
<p class="muted small" style="margin-top:30px">No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. LinkSpin sells advertising; it is not an investment.</p>
|
||||
<footer>
|
||||
<div>LinkSpin · <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=20260914a"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,489 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>LinkSpin: 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://linkspin-test.saasy.top/">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="LinkSpin">
|
||||
<meta property="og:url" content="https://linkspin-test.saasy.top/">
|
||||
<meta property="og:title" content="LinkSpin — Advertise and Earn Instantly">
|
||||
<meta property="og:description" content="Real ad packages with instant on-chain settlement. Every purchase pays the sponsor line in the same transaction — verifiable on the live ledger. Seven ad formats, earn credits back for your attention.">
|
||||
<meta property="og:image" content="https://linkspin-test.saasy.top/banners/iap-hero-1200x630.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="LinkSpin — Advertise and Earn Instantly">
|
||||
<meta name="twitter:description" content="Instant on-chain ad payouts, verifiable on a public ledger. Seven ad formats. Earn credits back for your attention.">
|
||||
<meta name="twitter:image" content="https://linkspin-test.saasy.top/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=20260912b">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<section class="hero">
|
||||
<div class="arcs">
|
||||
<svg viewBox="0 0 1200 620" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="arcGrad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#43e8c3" stop-opacity="0"/>
|
||||
<stop offset=".45" stop-color="#eafffa"/>
|
||||
<stop offset=".75" stop-color="#43e8c3"/>
|
||||
<stop offset="1" stop-color="#43e8c3" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<filter id="arcGlow" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="7" result="b"/>
|
||||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<path class="arc" d="M 700 150 C 880 90, 1080 130, 1220 260"/>
|
||||
<path class="arc2" d="M -30 430 C 160 520, 420 520, 560 400"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="streak" style="left:12%;animation-delay:0s"></span>
|
||||
<span class="streak" style="left:24%;animation-delay:2.1s"></span>
|
||||
<span class="streak" style="left:41%;animation-delay:4.4s"></span>
|
||||
<span class="streak" style="left:58%;animation-delay:1.2s"></span>
|
||||
<span class="streak" style="left:72%;animation-delay:3.4s"></span>
|
||||
<span class="streak" style="left:86%;animation-delay:5.6s"></span>
|
||||
<div class="wrap">
|
||||
<h1>Advertise and earn instantly.<br><em>Locked in code, not promises.</em></h1>
|
||||
<p class="lead">Buy real ad packages from 5 to 250 dollars. Every purchase settles through
|
||||
a smart contract you can read yourself, and your earnings land in
|
||||
<b>your own wallet</b> before the page even refreshes.</p>
|
||||
<p class="small muted" style="margin:-14px auto 26px;max-width:620px">One crypto, one network: packages are paid in <b style="color:var(--ink)">POL on Polygon</b> and every payout arrives as POL in your wallet. Buy POL with a card inside if you have never held any.</p>
|
||||
<div class="ctas">
|
||||
<a class="btn" href="/my">Join free</a>
|
||||
<a class="btn sec" href="/ledger">Watch payments land live</a>
|
||||
</div>
|
||||
<p class="small muted" id="sponsorLine" hidden style="margin-top:20px"></p>
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="n" id="stMembers">–</div><div class="l">On-chain members</div></div>
|
||||
<div class="stat"><div class="n" id="stPurchases">–</div><div class="l">Packages bought</div></div>
|
||||
<div class="stat"><div class="n" id="stPaid">–</div><div class="l">POL settled</div></div>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<div class="ticker" hidden id="ticker"><div class="inner" id="tickerInner"></div></div>
|
||||
|
||||
<section id="how">
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>Instant payments without compromise</h2>
|
||||
<p>Most referral programs run on a database someone can change after you promote.
|
||||
This one runs on Polygon, split by a contract nobody can touch. Not even us.</p>
|
||||
</div>
|
||||
<div class="plates">
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z"/></svg></div>
|
||||
<h3>Paid in the same transaction</h3>
|
||||
<p>The purchase and every payout are one blockchain event. No balances held, no withdrawal
|
||||
button, no company touching the money.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 3 8 7 10 4-2 7-5.5 7-10V6l-7-3z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
<h3>Rules that cannot move</h3>
|
||||
<p>50-20-10 across three levels plus a 20 percent platform fee, written as constants in an
|
||||
immutable contract. There is no function to change them.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5"/><path d="M11 8v6M8 11h6"/></svg></div>
|
||||
<h3>Verifiable by anyone</h3>
|
||||
<p>Every payment streams to a public ledger with a verify link straight to the block explorer.
|
||||
If it is not there, it did not happen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="different">
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>Why this is not another ad site</h2>
|
||||
<p>You have seen traffic exchanges, click-to-earn sites and solo-ad sellers. Here is the honest side by side.</p>
|
||||
</div>
|
||||
<div class="tablewrap"><table class="cmp">
|
||||
<tr><th></th><th>Typical advertising site</th><th>LinkSpin</th></tr>
|
||||
<tr><td>Referral commission</td><td>5 to 15 percent, often only after a minimum balance</td><td><b>50 percent</b> to the direct sponsor, then 20 and 10 on the next two levels</td></tr>
|
||||
<tr><td>When you get paid</td><td>Request a withdrawal, wait for approval, hope</td><td>In the <b>same transaction</b> the package sells</td></tr>
|
||||
<tr><td>Who holds the money</td><td>The site's balance, at the owner's discretion</td><td><b>Nobody.</b> The contract splits it to real wallets. There is no balance to hold</td></tr>
|
||||
<tr><td>Can the rules change</td><td>Whenever the owner edits a setting</td><td><b>Never.</b> 50 / 20 / 10 / 20 are constants in a verified, immutable contract</td></tr>
|
||||
<tr><td>Proof</td><td>A number on a dashboard</td><td>Every payout is a <b>public transaction</b> you can open yourself</td></tr>
|
||||
<tr><td>Ad views</td><td>Timers that run while nobody looks</td><td>A 10-second dwell, a human check, and a <b>server-side clock</b></td></tr>
|
||||
<tr><td>Credits</td><td>Points that expire or get devalued</td><td><b>1 credit = 1 cent</b> of delivery, recorded on-chain, spent only by your campaigns</td></tr>
|
||||
<tr><td>Joining</td><td>Install an app, connect a wallet, then maybe read</td><td><b>Email first.</b> The wallet comes when you are ready to be paid</td></tr>
|
||||
<tr><td>Advertising</td><td>Pay to be seen by people who are paid to click</td><td>Seven formats delivered to <b>members who buy ads themselves</b>, with verified visits and finished video views</td></tr>
|
||||
</table></div>
|
||||
<p class="small muted" style="text-align:center;margin-top:14px">No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. LinkSpin sells advertising; it is not an investment.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>Watch the money move</h2>
|
||||
<p>This is a live view, not a brochure. Real purchases split into real payouts,
|
||||
each one a click away from the raw transaction.</p>
|
||||
</div>
|
||||
<div class="flank">
|
||||
<div>
|
||||
<div class="flankitem">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M7 15h4"/></svg></div>
|
||||
<h3>Buy a package</h3>
|
||||
<p>Priced in dollars, settled in POL at the live oracle rate. Overpayment refunds itself in the same transaction.</p>
|
||||
</div>
|
||||
<div class="flankitem">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M12 3v13m0 0 5-5m-5 5-5-5"/><path d="M4 21h16"/></svg></div>
|
||||
<h3>The contract splits it</h3>
|
||||
<p>Half to the direct sponsor, then levels 2 and 3, then the platform. Automatically, immediately, every time.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mockup" aria-label="live ledger preview">
|
||||
<div class="bar"><i></i><i></i><i></i><span class="addr">linkspin-test.saasy.top/ledger</span></div>
|
||||
<div class="body" id="mockBody">
|
||||
<div class="mrow"><span>🧾 member #7 bought package #2 ($20.00)</span><span>−213 POL</span></div>
|
||||
<div class="mrow"><span>💸 level 1 payout → member #3</span><span>+106.5 POL</span></div>
|
||||
<div class="mrow"><span>💸 level 2 payout → member #2</span><span>+42.6 POL</span></div>
|
||||
<div class="mrow"><span>💸 level 3 payout → member #1</span><span>+21.3 POL</span></div>
|
||||
<div class="mrow dim"><span>🏛 platform fee settled</span><span>42.6 POL</span></div>
|
||||
<div class="mrow dim"><span>⭐ member #3 now has 2 qualifying buyers</span><span>level 2 ✓</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flankitem">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M8 8V6a4 4 0 0 1 8 0v2"/><circle cx="12" cy="14" r="1.6"/></svg></div>
|
||||
<h3>Straight to their wallets</h3>
|
||||
<p>Recipients get POL in their own wallets within seconds. Nothing to claim, nothing to request.</p>
|
||||
</div>
|
||||
<div class="flankitem">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M4 17l5-5 4 3 7-8"/><path d="M14 7h6v6"/></svg></div>
|
||||
<h3>Credits mint on-chain</h3>
|
||||
<p>One credit is one cent of ad delivery across the network. Only your campaigns can ever spend them.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small muted" style="text-align:center;margin-top:22px">Amounts shown are the $20 worked example at the current oracle rate. <a href="/ledger">Open the real ledger →</a></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="story">
|
||||
<div>
|
||||
<svg class="iso" id="genViz" data-lvl="1" viewBox="0 0 360 320" role="img" aria-label="Your position at the top of a three-generation network; each level lights up the generation that pays you">
|
||||
<defs>
|
||||
<linearGradient id="cubeTop" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#43e8c3"/><stop offset="1" stop-color="#1c7c65"/>
|
||||
</linearGradient>
|
||||
<filter id="softGlow" x="-60%" y="-60%" width="220%" height="220%">
|
||||
<feGaussianBlur stdDeviation="6" result="b"/>
|
||||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<g class="edge e1"><path d="M180 92 L120 138 M180 92 L240 138" fill="none" stroke-width="1.6"/></g>
|
||||
<g class="edge e2"><path d="M120 152 L75 205 M120 152 L150 205 M240 152 L210 205 M240 152 L285 205" fill="none" stroke-width="1.4"/></g>
|
||||
<g class="edge e3"><path d="M75 219 L45 272 M75 219 L95 272 M150 219 L130 272 M150 219 L170 272 M210 219 L195 272 M210 219 L232 272 M285 219 L268 272 M285 219 L315 272" fill="none" stroke-width="1.2"/></g>
|
||||
<g filter="url(#softGlow)">
|
||||
<path d="M180 38 L212 56 L180 74 L148 56 Z" fill="url(#cubeTop)"/>
|
||||
<path d="M148 56 L180 74 L180 110 L148 90 Z" fill="#0f4437"/>
|
||||
<path d="M212 56 L180 74 L180 110 L212 90 Z" fill="#16604c"/>
|
||||
</g>
|
||||
<text id="vizPct" x="236" y="66" font-size="26" font-weight="700" fill="#43e8c3" font-family="Sora,sans-serif">50%</text>
|
||||
<g class="gen g1">
|
||||
<circle class="node-o" cx="120" cy="145" r="11"/><circle class="node-d" cx="120" cy="145" r="3.4"/>
|
||||
<circle class="node-o" cx="240" cy="145" r="11"/><circle class="node-d" cx="240" cy="145" r="3.4"/>
|
||||
</g>
|
||||
<g class="gen g2">
|
||||
<circle class="node-o" cx="75" cy="212" r="9"/><circle class="node-d" cx="75" cy="212" r="2.8"/>
|
||||
<circle class="node-o" cx="150" cy="212" r="9"/><circle class="node-d" cx="150" cy="212" r="2.8"/>
|
||||
<circle class="node-o" cx="210" cy="212" r="9"/><circle class="node-d" cx="210" cy="212" r="2.8"/>
|
||||
<circle class="node-o" cx="285" cy="212" r="9"/><circle class="node-d" cx="285" cy="212" r="2.8"/>
|
||||
</g>
|
||||
<g class="gen g3">
|
||||
<circle class="node-o" cx="45" cy="280" r="7.5"/><circle class="node-d" cx="45" cy="280" r="2.3"/>
|
||||
<circle class="node-o" cx="95" cy="280" r="7.5"/><circle class="node-d" cx="95" cy="280" r="2.3"/>
|
||||
<circle class="node-o" cx="130" cy="280" r="7.5"/><circle class="node-d" cx="130" cy="280" r="2.3"/>
|
||||
<circle class="node-o" cx="170" cy="280" r="7.5"/><circle class="node-d" cx="170" cy="280" r="2.3"/>
|
||||
<circle class="node-o" cx="195" cy="280" r="7.5"/><circle class="node-d" cx="195" cy="280" r="2.3"/>
|
||||
<circle class="node-o" cx="232" cy="280" r="7.5"/><circle class="node-d" cx="232" cy="280" r="2.3"/>
|
||||
<circle class="node-o" cx="268" cy="280" r="7.5"/><circle class="node-d" cx="268" cy="280" r="2.3"/>
|
||||
<circle class="node-o" cx="315" cy="280" r="7.5"/><circle class="node-d" cx="315" cy="280" r="2.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Earn deeper as your people buy</h2>
|
||||
<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: 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">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>
|
||||
<li>Qualification never expires and can never be bought</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>Real ad inventory. Real eyeballs.</h2>
|
||||
<p>This is not phantom traffic or empty impressions. Seven ad formats run right now,
|
||||
reaching members who are themselves marketers buying traffic — and members earn credits back
|
||||
for the attention they give.</p>
|
||||
</div>
|
||||
<div class="plates">
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="12" rx="2"/><path d="M7 21h10M12 17v4"/></svg></div>
|
||||
<h3>Display banners <span class="badge">live</span></h3>
|
||||
<p>All standard sizes, live across our network, priced per impression. You pay for views, not guesses.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h9"/></svg></div>
|
||||
<h3>Text ads <span class="badge">live</span></h3>
|
||||
<p>A headline plus a support line, placed where members actually look. Also per impression, also live right now.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><circle cx="12" cy="9" r="3.5"/><path d="M5 20c1.5-3.5 4-5 7-5s5.5 1.5 7 5"/><rect x="3" y="3" width="18" height="18" rx="3"/></svg></div>
|
||||
<h3>Login ads <span class="badge">live</span></h3>
|
||||
<p>The moment a member signs in, your ad is the toll gate: they open your page in a fresh tab
|
||||
while a countdown holds their dashboard. Just a link is enough — add a banner if you have
|
||||
one. Priced per day.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m10 9 5 3-5 3z"/></svg></div>
|
||||
<h3>Video ads <span class="badge">live</span></h3>
|
||||
<p>Upload a video or drop a link, and pick how long members must watch — 10, 30 or 60 seconds.
|
||||
They watch in a player that can't be skipped, the clock runs on our server, and you pay only
|
||||
for completed views. There's a full-screen Shorts feed too.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M3 7l9 6 9-6"/><rect x="3" y="5" width="18" height="14" rx="2"/></svg></div>
|
||||
<h3>Solo ads <span class="badge">live</span></h3>
|
||||
<p>Your full message — rich text, an image or video, and a call-to-action button — delivered
|
||||
straight into member inboxes on-site and by email. Priced per guaranteed delivery, and readers
|
||||
earn credits for a real read, so your message gets opened, not skimmed past.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M12 3v4M12 17v4M3 12h4M17 12h4"/><circle cx="12" cy="12" r="4.5"/></svg></div>
|
||||
<h3>Featured rotation <span class="badge">live</span></h3>
|
||||
<p>Book your link into the featured rotation by the day — 1, 2 or 7 days — with a hard cap on how
|
||||
many links share a day and the occupancy shown before you buy. The only rotator that tells you
|
||||
the dilution up front, because hiding it is a sucker move.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg></div>
|
||||
<h3>Verified visits <span class="badge">live</span></h3>
|
||||
<p>Buy a pack of guaranteed unique human visits. Each one is a different member who stayed the
|
||||
full dwell and passed a human check — no bots, no recycled clicks, no repeats, just real visits
|
||||
you can count on.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>What you get as a member</h2>
|
||||
<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>
|
||||
<ul class="checks">
|
||||
<li>A member account and the live ledger</li>
|
||||
<li>Welcome credits to taste real ad delivery</li>
|
||||
<li>Earn more credits by viewing ads, watching videos, making verified visits and reading solo ads</li>
|
||||
<li>Your line banner, shown to your next three levels as they join</li>
|
||||
<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>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">
|
||||
<h3>Any package adds</h3>
|
||||
<ul class="checks">
|
||||
<li>On-chain ad credits minted the moment you buy</li>
|
||||
<li>All seven ad formats, with live stats and a dashboard of charts per campaign</li>
|
||||
<li>Top up any campaign anytime, and message your whole downline</li>
|
||||
<li>A full arsenal of promotional tools and a ready-made banner kit, personalized with your link</li>
|
||||
<li>Packages of $20 or more count toward qualification</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="packages">
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>The ad packages</h2>
|
||||
<p>Priced in dollars, settled in POL at the moment you buy. Packages of $20 or more
|
||||
count toward your sponsor's qualification.</p>
|
||||
</div>
|
||||
<div class="tiles" id="tiles"><div class="tile"><span class="muted small">Loading live prices from the contract…</span></div></div>
|
||||
<div class="split" aria-label="Where every dollar goes">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>Run your what-if</h2>
|
||||
<p>Play with a scenario and see how the contract would split it. This is arithmetic on the
|
||||
locked percentages, not a prediction and not a promise. Nobody earns a cent unless real
|
||||
people really buy advertising.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="grid c2">
|
||||
<div>
|
||||
<p class="small muted" style="margin-bottom:4px">Direct referrals who each buy a package</p>
|
||||
<p><input type="range" id="dcDirects" min="0" max="20" value="2" style="width:100%">
|
||||
<span class="mono" id="dcDirectsV">2</span></p>
|
||||
<p class="small muted" style="margin-bottom:4px">The package they buy</p>
|
||||
<p><select id="dcPkg" style="width:100%">
|
||||
<option value="5">Micro, $5</option>
|
||||
<option value="20" selected>Activation, $20</option>
|
||||
<option value="50">Builder, $50</option>
|
||||
<option value="100">Growth, $100</option>
|
||||
<option value="250">Leader, $250</option>
|
||||
</select></p>
|
||||
<p class="small muted" style="margin-bottom:4px">Referrals each of them brings who also buy</p>
|
||||
<p><input type="range" id="dcSpread" min="0" max="10" value="2" style="width:100%">
|
||||
<span class="mono" id="dcSpreadV">2</span></p>
|
||||
<p class="small muted" id="dcQualNote"></p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tablewrap"><table>
|
||||
<thead><tr><th>Level</th><th>People buying</th><th class="num">Your share</th><th class="num">You receive</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Level 1 <span class="badge" id="dcB1">open</span></td><td class="num" id="dcN1">–</td><td class="num">50%</td><td class="num mono" id="dcE1">–</td></tr>
|
||||
<tr><td>Level 2 <span class="badge amber" id="dcB2">locked</span></td><td class="num" id="dcN2">–</td><td class="num">20%</td><td class="num mono" id="dcE2">–</td></tr>
|
||||
<tr><td>Level 3 <span class="badge amber" id="dcB3">locked</span></td><td class="num" id="dcN3">–</td><td class="num">10%</td><td class="num mono" id="dcE3">–</td></tr>
|
||||
<tr><td colspan="3"><b>If every one of those purchases happens</b></td><td class="num mono" style="color:var(--mint)"><b id="dcTotal">–</b></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<p class="small muted" style="margin-top:10px">And that is one round of purchases. The same
|
||||
split runs again on every future package the same people buy.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small muted" style="margin-top:14px;border-top:1px solid var(--line);padding-top:14px">
|
||||
<b style="color:var(--mint)">The pass-up rule:</b> a locked level's share does not disappear.
|
||||
The contract climbs the sponsor line, checking up to 25 positions, and pays the first qualified
|
||||
person it finds; only if nobody in those 25 qualifies does the share go to the platform. It cuts
|
||||
both ways: stay qualified and you catch the shares that under-qualified positions below you let
|
||||
slip. Every pass-up is a visible event on the <a href="/ledger">ledger</a>, so you can see
|
||||
exactly where money climbed past someone and why.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="card" id="adSlotHome" hidden style="text-align:center"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>Questions people actually ask</h2>
|
||||
</div>
|
||||
<details><summary>Is this a pyramid or ponzi scheme?</summary>
|
||||
<p>No, and here is the plain version of why. A pyramid scheme pays you just for recruiting, with no
|
||||
real product behind it. Here every payment is for ad delivery, and the payouts only come from real
|
||||
buyers who buy real ad packages. You earn from people actually advertising, not from building a chain
|
||||
of signups. The whole thing runs on a public ledger you can verify yourself, so nothing stays hidden.</p></details>
|
||||
<details><summary>What happens if the company disappears?</summary>
|
||||
<p>The important part is that there is no company holding the money. Every sale is split by the smart
|
||||
contract itself, on the blockchain, in the same transaction. Nobody can pause it, change it, or run
|
||||
off with the funds, because the code does the paying automatically. If the site vanished tomorrow,
|
||||
the contract would keep paying exactly what it is set to pay.</p></details>
|
||||
<details><summary>Do I need crypto experience or a wallet to join?</summary>
|
||||
<p>No. You can start with a free email signup and use the platform the same way you would use any ad
|
||||
site. If you want payouts to a wallet, setting one up takes a few minutes and the platform walks you
|
||||
through it. But you do not need to know anything about crypto to get in and get moving.</p></details>
|
||||
<details><summary>How fast do I really get paid?</summary>
|
||||
<p>Payouts happen inside the very transaction that pays for the package. There is no approval step
|
||||
and nobody holding funds. Your share lands in your own wallet the moment your referral's purchase
|
||||
confirms. The public ledger lets you watch each one go through.</p></details>
|
||||
<details><summary>What am I actually buying?</summary>
|
||||
<p>You are buying ad delivery. Credits are one cent of delivery each, recorded on-chain when you buy.
|
||||
The 5 dollar package mints 500 credits, and the bigger packages mint bonus credits on top, up to
|
||||
32,500 on the 250 dollar package. That is the product, plain and simple. Your earnings come from
|
||||
referring people who also buy real ad delivery, and the contract splits every purchase automatically.</p></details>
|
||||
|
||||
<div class="closer">
|
||||
<div>
|
||||
<h2>See a payment land before you decide.</h2>
|
||||
<p class="muted" style="margin:0 0 22px">The ledger is open to everyone. Watch real purchases
|
||||
split and settle, then join free when you have seen enough.</p>
|
||||
<a class="btn" href="/my">Join free</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">live · polygon</span></div>
|
||||
<div class="body">
|
||||
<div class="mrow"><span>💸 payout → member #3</span><span>instant</span></div>
|
||||
<div class="mrow"><span>💸 payout → member #2</span><span>instant</span></div>
|
||||
<div class="mrow dim"><span>🔍 verify on explorer</span><span>↗</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div>LinkSpin · every payment verifiable on-chain · <a href="/ledger">live ledger</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">view the contract ↗</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>
|
||||
<div class="ghost" aria-hidden="true">LinkSpin</div>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/wallet.js?v=20260909c"></script>
|
||||
<script src="/assets/home.js?v=20260915b"></script>
|
||||
<script src="/assets/chat.js?v=20260906m"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +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 | LinkSpin</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="LinkSpin" 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">linkspin-test.saasy.top/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">
|
||||
LinkSpin · <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=20260914a"></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 | LinkSpin</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. LinkSpin sells advertising; it is not an investment.</p>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div>LinkSpin · <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=20260914a"></script>
|
||||
<script src="/assets/launch.js?v=20260915b"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Live ledger | LinkSpin</title>
|
||||
<meta name="description" content="Every purchase, payout, and pass-up on LinkSpin, streamed straight from the blockchain with a verify link on every line.">
|
||||
<link rel="canonical" href="https://linkspin-test.saasy.top/ledger">
|
||||
<meta property="og:type" content="website"><meta property="og:site_name" content="LinkSpin">
|
||||
<meta property="og:url" content="https://linkspin-test.saasy.top/ledger">
|
||||
<meta property="og:title" content="LinkSpin — 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://linkspin-test.saasy.top/banners/iap-hero-1200x630.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="LinkSpin — 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://linkspin-test.saasy.top/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>LinkSpin · <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=20260914a"></script>
|
||||
<script src="/assets/ledger.js?v=20260906m"></script>
|
||||
<script src="/assets/chat.js?v=20260906m"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 276 KiB |
@@ -0,0 +1,134 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>For site owners | LinkSpin</title>
|
||||
<meta name="description" content="Marty's partner kit for site owners who run a downline builder: the platform in three minutes, how the money moves, what your members get, and what you get for listing LinkSpin.">
|
||||
<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=20260910h">
|
||||
<style>
|
||||
.pk{max-width:820px}
|
||||
.pk h2{font-size:24px;margin:44px 0 12px;padding-top:20px;border-top:1px solid var(--line)}
|
||||
.pk p{max-width:68ch}
|
||||
.pk ul{margin:0 0 14px 20px;padding:0;max-width:68ch} .pk li{margin:7px 0}
|
||||
.pk ol{margin:0 0 14px 20px;padding:0;max-width:68ch} .pk ol li{margin:9px 0}
|
||||
.hello{display:none;margin:0 0 18px;padding:12px 16px;border:1px solid var(--mint);border-radius:12px;color:var(--mint);font-weight:700}
|
||||
.vid{background:#000;border:1px solid var(--line);border-radius:14px;overflow:hidden;margin:18px 0 8px}
|
||||
.vid video{display:block;width:100%;aspect-ratio:16/9}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin:16px 0}
|
||||
.grid .card{margin:0;padding:16px} .grid .card h3{margin:0 0 6px;font-size:16px} .grid .card p{margin:0;font-size:14px;color:var(--muted)}
|
||||
.split{display:grid;grid-template-columns:5fr 2fr 1fr 2fr;gap:8px;margin:16px 0 8px;text-align:center}
|
||||
.split>div{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 8px;font-size:13px;color:var(--muted)}
|
||||
.split .n{font-family:var(--disp);font-size:26px;font-weight:800;color:var(--mint)}
|
||||
table{border-collapse:collapse;width:100%;margin:10px 0 18px;font-variant-numeric:tabular-nums;font-size:15px}
|
||||
th,td{text-align:left;padding:9px 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:nth-child(2),td:nth-child(3),th:nth-child(2),th:nth-child(3){text-align:right}
|
||||
.get{border:1px solid #f2c94c;background:rgba(242,201,76,.08);border-radius:14px;padding:18px 22px;margin:16px 0}
|
||||
.get h3{color:#f2c94c;margin:0 0 8px}
|
||||
.cta{margin:26px 0 8px;padding:22px;border:1px solid var(--mint);border-radius:14px;background:rgba(67,232,195,.06)}
|
||||
.cta .btn{margin-top:10px}
|
||||
code{background:rgba(4,8,7,.6);padding:2px 7px;border-radius:6px;font-size:13.5px;word-break:break-all}
|
||||
@media (max-width:640px){.split{grid-template-columns:1fr 1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap pk">
|
||||
<section class="hero" style="padding:60px 0 10px">
|
||||
<p class="eyebrow">For site owners with a downline builder</p>
|
||||
<h1>I built the ad platform I always wanted to run. <em>I want it in your builder.</em></h1>
|
||||
<p class="lead">A note from me, plus everything you need to list LinkSpin in your builder and hand your members free ad credits with a code of your own.</p>
|
||||
</section>
|
||||
|
||||
<p class="hello" id="pkHello"></p>
|
||||
|
||||
<div class="vid"><video id="pkVideo" controls playsinline preload="metadata" poster="/promo/partner-overview.jpg" src="https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/partner-overview.mp4"></video></div>
|
||||
<p class="muted small">Watch first: the whole platform in about five minutes. Free and paid members, the seven ad formats, how people get paid, and the offer for your builder.</p>
|
||||
|
||||
<h2>Why I built it</h2>
|
||||
<p>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.</p>
|
||||
<p>So I built LinkSpin from scratch as part of the Crypto Team Build Network. No vendor, no license server, and the part that always went wrong on ad sites, the money, is handled by a verified smart contract on Polygon instead of by me. When a package sells, the contract splits the payment and sends it in the same transaction. I never hold member funds, so there is no back office, no payday, and nothing anyone can switch off.</p>
|
||||
<p class="muted">Marty Bostick · Crypto Team Build Network</p>
|
||||
|
||||
<h2>What LinkSpin is</h2>
|
||||
<p>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.</p>
|
||||
<div class="grid">
|
||||
<div class="card"><h3>Seven ad formats</h3><p>Banners, text ads, login ads, solo ads to member inboxes, video, featured links and verified visits. Views are timed on the server, so a real person saw the ad.</p></div>
|
||||
<div class="card"><h3>Beyond the site</h3><p>Banner and text ads also push out to Network Ad Space, a partner rotation across other member sites. Those impressions count in the member's stats.</p></div>
|
||||
<div class="card"><h3>WalletConnect built in</h3><p>Full WalletConnect integration: members link MetaMask, Trust, Phantom, SafePal or any WalletConnect wallet with one tap, sign once, and buy or get paid straight from that wallet. No custody on my side.</p></div>
|
||||
<div class="card"><h3>Public, verified, immutable</h3><p>Every payout is a public transaction on Polygon. The split percentages and qualification rules are constants in a verified contract that the operator cannot change.</p></div>
|
||||
</div>
|
||||
|
||||
<h2>How the money moves</h2>
|
||||
<p>Every ad package splits the same way, in the same transaction it sells in:</p>
|
||||
<div class="split">
|
||||
<div><div class="n">50%</div>direct sponsor</div>
|
||||
<div><div class="n">20%</div>level 2</div>
|
||||
<div><div class="n">10%</div>level 3</div>
|
||||
<div><div class="n">20%</div>platform</div>
|
||||
</div>
|
||||
<p class="muted small">On a $20 package: $10 to the direct sponsor, $4 to level 2, $2 to level 3, $4 to the platform. If a level has no qualified member, that share passes up to the next qualified person above.</p>
|
||||
<p>Qualification is earned, never bought. Every direct buyer pays their sponsor 50% from their very first package. Two qualifying buyers, people who bought a $20 or larger package, open level 2. Five open level 3. Until a level opens, its share climbs to the next qualified member above, which is why the plan rewards the people who actually build.</p>
|
||||
<table>
|
||||
<tr><th>Package</th><th>Price</th><th>Credits</th></tr>
|
||||
<tr><td>Micro</td><td>$5</td><td>500</td></tr>
|
||||
<tr><td>Activation, the qualifying buy</td><td>$20</td><td>2,000</td></tr>
|
||||
<tr><td>Builder</td><td>$50</td><td>5,500</td></tr>
|
||||
<tr><td>Growth</td><td>$100</td><td>12,000</td></tr>
|
||||
<tr><td>Leader</td><td>$250</td><td>32,500</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>What your members get</h2>
|
||||
<ul>
|
||||
<li><b>A free start.</b> Join by email, view a few ads, earn credits, run a real banner or text campaign for nothing.</li>
|
||||
<li><b>Your promo credits on top.</b> Members who arrive with your code get free ad credits added the moment they join, in addition to everything else.</li>
|
||||
<li><b>Instant, public payouts.</b> When anyone in their line buys ads, the contract pays them in POL to their own wallet in the same transaction. They can check every payment on Polygonscan.</li>
|
||||
<li><b>A seamless wallet step.</b> Full WalletConnect integration means linking a wallet is one tap and one free signature from any major wallet app, and purchases confirm inside the wallet they already use.</li>
|
||||
<li><b>Tools that do the work.</b> A ready-to-send invite message, social posts, email swipes, a full banner kit, objection answers, and a public profile wall with their own banner slots.</li>
|
||||
<li><b>Training that keeps growing.</b> A video series that walks the whole member area, plus written plays for building a line.</li>
|
||||
<li><b>A holding tank.</b> Members who arrive without a sponsor are not lost. Qualified builders adopt them, first come, first served.</li>
|
||||
<li><b>Coaching built in.</b> A next-move card on every dashboard, nudges when a referral stalls, and a live payments topic on Telegram where every payout posts as it lands.</li>
|
||||
</ul>
|
||||
|
||||
<div class="get">
|
||||
<h3>What you get for listing it in your builder</h3>
|
||||
<ul>
|
||||
<li><b>Your spot at the top.</b> You join directly under the company at the top, no sponsor in between. All it takes to claim that spot is activating your account with at least the $20 package, and that locks you in at the top of your own line from day one.</li>
|
||||
<li><b>Your own promo code.</b> A reusable code that adds free ad credits for every member who redeems it, on your builder link or in the dashboard. I set the credit amount, an optional cap and an optional expiry. One redemption per account, every redemption logged, and you can see uses any time.</li>
|
||||
<li><b>Your own line.</b> Every member who comes through your builder link lands under you. Each one who buys pays you 50% of their first package and every package after it, and their buyers open your level 2 and level 3 shares.</li>
|
||||
<li><b>Ready-made creatives.</b> Banners in every builder size (468x60, 728x90, 300x250, 160x600, 120x600, 1200x630), text ad copy, email swipes and a program description you can paste into your listing.</li>
|
||||
<li><b>A bridge page for your brand, on request.</b> A landing page in this design that names your site, explains the connection, and carries your code.</li>
|
||||
<li><b>Attribution you can check.</b> Signups and buyers are tagged with the source they came from, and the promo code report shows exactly who redeemed yours.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Setting it up takes about fifteen minutes</h2>
|
||||
<ol>
|
||||
<li>Claim your spot with the button below. It places you directly under the company at the top. Join with your email and pick your username; your own invite link is live immediately: <code>linkspin-test.saasy.top/join/yourname</code>.</li>
|
||||
<li>Send me your username and the site you are listing it on. I mint your code with the credit amount we agree on.</li>
|
||||
<li>Add LinkSpin to your downline builder with your link plus the code: <code id="pkExample">https://linkspin-test.saasy.top/join/yourname?promo=YOURCODE</code>. Members who click it land under you and their credits apply the moment their account exists.</li>
|
||||
<li>Use the banners and text ads from the kit. Members who already have an account can type the code into the "Have a promo code?" box on their Overview.</li>
|
||||
<li>Link a wallet, switch on payouts, and activate with at least the $20 package. That claims your spot at the top and makes you a qualifying buyer in your own right.</li>
|
||||
</ol>
|
||||
|
||||
<div class="cta">
|
||||
<b id="pkCtaHead">Claim your spot at the top</b>
|
||||
<p class="muted small" id="pkCtaSub" style="margin:6px 0 0">Free account by email. No password, no wallet today. The link below places you directly under the company.</p>
|
||||
<a class="btn" id="pkCta" href="/join/company">Claim my spot</a>
|
||||
</div>
|
||||
|
||||
<h2>The honest part</h2>
|
||||
<p>LinkSpin sells advertising. Members earn from the ad packages people in their line buy, and nothing else. There is no earn-without-referring option, on purpose, because sites that pay you just for buying in are the ones that collapse. No income is guaranteed, results depend on effort, and cryptocurrency involves risk of loss. The contract, the ledger and every payout are public, so you never have to take my word for any of it.</p>
|
||||
<p>Marty Bostick · <a href="mailto:marty@marketingwithmarty.com">marty@marketingwithmarty.com</a> · <a href="https://t.me/cryptoteambuild">t.me/cryptoteambuild</a></p>
|
||||
|
||||
<footer>
|
||||
<div>LinkSpin · <a href="/">home</a> · <a href="/ledger">live ledger</a> · <a href="/contract">the contract</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 to lose.</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/partners.js?v=20260914a"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,198 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Team-building plays | LinkSpin</title>
|
||||
<meta name="description" content="Three ways to build your LinkSpin line, plus the Qualified Start opening move. 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=20260910h">
|
||||
<style>
|
||||
.pl-rule{border-left:4px solid #f2c94c;background:rgba(242,201,76,.08);padding:16px 20px;border-radius:0 12px 12px 0;margin:0 0 28px}
|
||||
.pl-rule b{font-family:var(--disp);font-size:17px;display:block;margin-bottom:6px}
|
||||
.pl-split{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 10px}
|
||||
.pl-split>div{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 10px;text-align:center}
|
||||
.pl-split .n{font-family:var(--disp);font-size:28px;font-weight:800;color:var(--mint);font-variant-numeric:tabular-nums}
|
||||
.pl-split .l{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-top:2px}
|
||||
.pl-ladder{display:grid;grid-template-columns:repeat(5,1fr);border:1px solid var(--line);border-radius:12px;overflow:hidden;background:var(--panel);margin:0 0 34px}
|
||||
.pl-ladder>div{padding:12px 10px;border-right:1px solid var(--line);font-size:13.5px}
|
||||
.pl-ladder>div:last-child{border-right:0}
|
||||
.pl-ladder .k{font-family:var(--mono);font-size:11px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase}
|
||||
.pl-ladder .v{font-weight:700;margin-top:2px}
|
||||
.pl-ladder .b{color:var(--mint);font-size:12.5px}
|
||||
.play header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:6px}
|
||||
.play h2{font-size:24px;margin:0}
|
||||
.play .tag{font-family:var(--mono);font-size:11.5px;color:var(--mint);border:1px solid rgba(67,232,195,.4);border-radius:999px;padding:3px 10px}
|
||||
.play ol{margin:0 0 14px 20px;padding:0;line-height:1.6}
|
||||
.play ol li{margin:0 0 8px}
|
||||
.meta{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px}
|
||||
.meta>div{background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:14px}
|
||||
.meta .k{font-family:var(--mono);font-size:11px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin-bottom:4px}
|
||||
.rec{border-color:var(--mint)}
|
||||
.cal{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
|
||||
.cal>div{background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:14px}
|
||||
.cal .k{font-family:var(--disp);font-weight:800;color:var(--mint);margin-bottom:6px}
|
||||
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);font-style:italic}
|
||||
blockquote span{display:block;font-style:normal;font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mint);margin-bottom:4px}
|
||||
.fit-table th{text-align:left;color:var(--muted);font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;font-weight:500}
|
||||
.fit-table td,.fit-table th{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
#gate{display:none}
|
||||
#printable{display:none}
|
||||
@media print{ body{background:#fff;color:#000} .wrap>*{display:none!important} #printable{display:block!important;color:#000} #printable h2,#printable h3{color:#000;font-family:Georgia,serif} #printable li{margin:6px 0} }
|
||||
@media (max-width:720px){.pl-split{grid-template-columns:repeat(2,1fr)}.pl-ladder{grid-template-columns:1fr}.pl-ladder>div{border-right:0;border-bottom:1px solid var(--line)}.meta,.cal{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero" style="padding:70px 0 24px">
|
||||
<p class="eyebrow">Member training</p>
|
||||
<h1>Three ways to <em>build a line</em>.</h1>
|
||||
<p class="lead">Pick one and run it. Every number here comes from the live contract and the rate table, not from a slide.</p>
|
||||
</section>
|
||||
|
||||
<div class="card" id="gate"><h3>Members only</h3><p class="muted small">Sign in to your member area to read the plays. <a href="/my">Sign in</a></p></div>
|
||||
|
||||
<div id="body">
|
||||
<div class="pl-rule">
|
||||
<b>The one rule under all three plays: unqualified levels pass up.</b>
|
||||
If someone on your level 2 buys before you have two qualifying buyers, that 20% does not wait for you. It goes to the next qualified sponsor above you, or to the platform. Same for level 3 and five. Whatever play you run, the first job is the same: get qualified before your line gets busy.
|
||||
</div>
|
||||
|
||||
<p class="eyebrow">How a package splits, the moment it sells</p>
|
||||
<div class="pl-split">
|
||||
<div><div class="n">50%</div><div class="l">Direct sponsor</div></div>
|
||||
<div><div class="n">20%</div><div class="l">Level 2 · needs 2</div></div>
|
||||
<div><div class="n">10%</div><div class="l">Level 3 · needs 5</div></div>
|
||||
<div><div class="n">20%</div><div class="l">Platform</div></div>
|
||||
</div>
|
||||
<p class="eyebrow" style="margin-top:18px">The ladder on your Overview, and what each rung unlocks</p>
|
||||
<div class="pl-ladder">
|
||||
<div><div class="k">Rung 1</div><div class="v">Joined</div><div class="b">Welcome tour: 25 credits</div></div>
|
||||
<div><div class="k">Rung 2</div><div class="v">Payouts on</div><div class="b">Spark badge · 10 credits</div></div>
|
||||
<div><div class="k">Rung 3</div><div class="v">First buyer</div><div class="b">Surge · 25 credits · 50% starts</div></div>
|
||||
<div><div class="k">Rung 4</div><div class="v">2 qualifying</div><div class="b">Circuit · 50 credits · level 2 · wall position 2</div></div>
|
||||
<div><div class="k">Rung 5</div><div class="v">5 qualifying</div><div class="b">Nexus · 100 credits · level 3 · wall position 3</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card play">
|
||||
<header><h2>Opening move · Qualified Start</h2><span class="tag">works with any play</span></header>
|
||||
<p class="muted"><b>Fits:</b> anyone who would rather start qualified than wait for their first two buyers.</p>
|
||||
<p>Qualification is earned by buyers, never bought. But you can be your own first buyers, openly. Under <a href="/my#buy">Buy packages</a>, link a second wallet you own as a <b>position</b>. When it buys a $20 package the contract counts it as a qualifying buyer, half the purchase comes straight back to your main wallet, and its credits pool with yours. Two positions open level 2 the same day; five open level 3. Then run whichever play fits you with the ladder already climbed. The three Qualified Start videos in <a href="/my#training">Training</a> show every click.</p>
|
||||
<div class="meta">
|
||||
<div><div class="k">Net cost</div>Level 2: about $20 net for $40 of ad credits. Level 3: about $50 net for $100 of credits. Plus a little POL for gas in each wallet.</div>
|
||||
<div><div class="k">Say it plainly</div>Your own money, your own wallets, a faster start. Never an income promise: qualification only pays on future purchases in your line.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card play">
|
||||
<header><h2>Play 1 · Wide and teach</h2><span class="tag">the fifty play</span></header>
|
||||
<p class="muted"><b>Fits:</b> someone with an audience, a list, a group, or traffic they can point somewhere.</p>
|
||||
<p>Every direct who buys is 50% to you, instantly, forever. Directs are the only thing that qualifies you. The teaching is what fills levels 2 and 3 without extra work from you: your directs' buyers are your 20%, their buyers are your 10%.</p>
|
||||
<ol>
|
||||
<li><b>One new conversation a day, minimum.</b> Text a friend and Social posts in Promo tools already carry your link.</li>
|
||||
<li><b>Send paid traffic to an angle lander, not the bare link.</b> Add <span class="mono">?v=adspend</span> for advertisers, <span class="mono">?v=free</span> for freebie seekers, <span class="mono">?v=instant</span> for the crypto-curious.</li>
|
||||
<li><b>Every new direct gets the same three sentences inside 24 hours</b> (sponsor chat or the daily broadcast): pick your username, link your wallet and switch on payouts, send your link to one person today. That is the whole teaching. They pass it down.</li>
|
||||
<li><b>Run the network's own ads at your link.</b> Buy a package or claim the daily 5 credits, then spend credits on a Featured link (40 credits a day) or a text ad pointed at your angle lander.</li>
|
||||
</ol>
|
||||
<div class="meta">
|
||||
<div><div class="k">Scoreboard</div>Joined your line climbing daily · Qualifying buyers 2, then 5 · level 2 and 3 rows appearing in My line</div>
|
||||
<div><div class="k">Ceiling and weakness</div>No ceiling on width. Shallow lines churn if you skip step 3.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card play">
|
||||
<header><h2>Play 2 · Two, then down</h2><span class="tag">the depth play</span></header>
|
||||
<p class="muted"><b>Fits:</b> someone with a small circle who would rather coach two people well than pitch twenty.</p>
|
||||
<p>Two qualifying buyers open level 2, wall position 2, the Circuit badge and 50 bonus credits. From there every person your two bring in pays you 20%, and every person those people bring in pays you 10% once you reach five. Your effort goes into two relationships instead of a funnel.</p>
|
||||
<ol>
|
||||
<li><b>Get two directs to a $20+ package.</b> Sit with them on the buy if you have to. Trust Wallet needs a POL cushion; SafePal or MetaMask are smoother.</li>
|
||||
<li><b>Coach them to their two.</b> Sponsor chat daily for the first week. One broadcast a day to your directs with a single ask each time.</li>
|
||||
<li><b>Set your line banner to your team's meeting place</b> (a Telegram group, a training page). Every new member three levels down meets it on their welcome tour.</li>
|
||||
<li><b>Keep adding directs until you have five.</b> This is the catch: level 3 only opens on five qualifying directs of your own. Two deep, coached well, earns a healthy 20% level. It does not open the 10% level.</li>
|
||||
</ol>
|
||||
<div class="meta">
|
||||
<div><div class="k">Scoreboard</div>Qualifying buyers 2 · level 2 count in My line rising · your directs' own qualifying counts</div>
|
||||
<div><div class="k">Ceiling and strength</div>Level 2 income until you personally hit five. The stickiest lines come from this play.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card play rec">
|
||||
<p class="eyebrow" style="margin:0 0 6px">Recommended default</p>
|
||||
<header><h2>Play 3 · Five and wide</h2><span class="tag">the combination</span></header>
|
||||
<p class="muted"><b>Fits:</b> anyone willing to do both. This is the play the dashboard ladder is actually built for.</p>
|
||||
<ol>
|
||||
<li><b>Sprint to five qualifying directs.</b> Nothing else matters until level 3 is open: Nexus, wall position 3 (your whole public page runs your own links), 100 bonus credits, and the full 50 / 20 / 10.</li>
|
||||
<li><b>Then split the day.</b> Mornings wide: one new conversation, one post, one ad running. Evenings deep: read My line, message the three newest directs, send the broadcast.</li>
|
||||
<li><b>Coach the 2-then-5 rule down the line.</b> Each of your five gets pushed to two (your level 2 fills), then to five (your level 3 fills). Use the achievements Share links; people copy what they see rewarded.</li>
|
||||
<li><b>Book the featured strip for 7 days</b> whenever you have 280 credits spare. Ten slots a day, every member sees it.</li>
|
||||
</ol>
|
||||
<div class="meta">
|
||||
<div><div class="k">Scoreboard</div>All four Overview tiles, plus Earning levels: buyers referred, level open, how many to next</div>
|
||||
<div><div class="k">Why it wins</div>Width qualifies you. Depth pays you on other people's effort. Only this play does both on purpose.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Which play fits you</h3>
|
||||
<div class="tablewrap"><table class="fit-table">
|
||||
<tr><th>You have</th><th>Run</th><th>First target</th></tr>
|
||||
<tr><td>A list, a group, or ad budget</td><td>Wide and teach</td><td>Five qualifying directs in 30 days</td></tr>
|
||||
<tr><td>A few close people and patience</td><td>Two, then down</td><td>Two qualifying directs in 14 days, both coached to their two</td></tr>
|
||||
<tr><td>An hour a day and a phone</td><td>Five and wide</td><td>Five qualifying, then one wide and one deep action every day</td></tr>
|
||||
</table></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>First 30 days, any play</h3>
|
||||
<div class="cal">
|
||||
<div><div class="k">Day 1</div>Username. Wallet linked. Payouts on. Welcome tour done (25 credits). Link sent to one person.</div>
|
||||
<div><div class="k">Days 2 to 7</div>One conversation a day. Claim the daily 5 credits. First buyer (25 bonus credits).</div>
|
||||
<div><div class="k">Days 8 to 14</div>Second qualifying buyer. Level 2 open. Line banner set. First broadcast sent.</div>
|
||||
<div><div class="k">Days 15 to 30</div>Coach the two to their two. Add directs three, four, five. Level 3 open by day 30 is the stretch goal.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Messages that fit each play</h3>
|
||||
<blockquote><span>Wide</span>I run ads anyway. This one pays me in the same transaction the buyer's package sells, on a public ledger. Free to join by email: <span class="mono" data-link>your link</span></blockquote>
|
||||
<blockquote><span>Depth</span>I need two people who will actually do this with me, not twenty who will look at it. You are one of the two I thought of. <span class="mono" data-link>your link</span></blockquote>
|
||||
<blockquote><span>Combination · to a new direct</span>Three things today: username, wallet on, one person. I will check in tomorrow.</blockquote>
|
||||
</div>
|
||||
|
||||
<p class="muted small">No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. LinkSpin sells advertising; it is not an investment.</p>
|
||||
<p><button class="btn sec" type="button" id="printBtn">Print the Qualified Start checklist and 30-day calendar</button></p>
|
||||
<div class="card" id="adSlotPlays" hidden style="text-align:center"></div>
|
||||
</div>
|
||||
|
||||
<div id="printable">
|
||||
<h2>LinkSpin: Qualified Start checklist</h2>
|
||||
<ol>
|
||||
<li>Username chosen (Profile tab). It is permanent: it becomes your invite link.</li>
|
||||
<li>Main wallet linked (Wallet tab, one free signature).</li>
|
||||
<li>Payouts switched on (Wallet tab, one free transaction).</li>
|
||||
<li>Extra wallet accounts created in your wallet app: IAP Position 2, 3, 4, 5.</li>
|
||||
<li>Each extra account funded with enough POL for a $20 package plus gas.</li>
|
||||
<li>Buy packages: Add a position, tick only the new account, sign once.</li>
|
||||
<li>Buy from: choose the position, Buy $20, confirm in the wallet.</li>
|
||||
<li>Repeat. Two positions open level 2. Five open level 3 and the Nexus badge.</li>
|
||||
</ol>
|
||||
<h2>First 30 days, any play</h2>
|
||||
<h3>Day 1</h3><ul><li>Username, wallet linked, payouts on, welcome tour done.</li><li>Link sent to one person.</li></ul>
|
||||
<h3>Days 2 to 7</h3><ul><li>One conversation a day.</li><li>Claim the daily 5 credits.</li><li>First buyer.</li></ul>
|
||||
<h3>Days 8 to 14</h3><ul><li>Second qualifying buyer. Level 2 open.</li><li>Line banner set. First broadcast sent.</li></ul>
|
||||
<h3>Days 15 to 30</h3><ul><li>Coach the two to their two.</li><li>Add directs three, four, five. Level 3 open by day 30 is the stretch goal.</li></ul>
|
||||
<p>My invite link: <span data-link>__________________________</span></p>
|
||||
<p style="font-size:12px">No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. LinkSpin sells advertising; it is not an investment.</p>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div>LinkSpin · <a href="/my#training">back to Training</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=20260914a"></script>
|
||||
<script src="/assets/plays.js?v=20260910b"></script>
|
||||
</body>
|
||||
</html>
|
||||