Admin: member search + drilldown card (identity, chain, credits, line, purchases, payouts, campaigns) with username/sponsor/wallet/credits/delete actions; sortable + filterable admin tables; Earn tab zero-credit button wording
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+32
@@ -201,6 +201,23 @@ const J = {
|
|||||||
if (p.memberId) return { error: 'That position is already registered on-chain and cannot be unlinked.' };
|
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 };
|
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 ----
|
// ---- MySQL mode ----
|
||||||
@@ -350,6 +367,18 @@ const D = {
|
|||||||
if (r.member_id) return { error: 'That position is already registered on-chain and cannot be unlinked.' };
|
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 };
|
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;
|
const impl = () => db.enabled() ? D : J;
|
||||||
@@ -446,6 +475,9 @@ module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUs
|
|||||||
setWallOffers: (e, j) => impl().setWallOffers(String(e || '').toLowerCase(), j),
|
setWallOffers: (e, j) => impl().setWallOffers(String(e || '').toLowerCase(), j),
|
||||||
setProfile: (e, a, bio, socials) => impl().setProfile(String(e || '').toLowerCase(), a, bio, socials),
|
setProfile: (e, a, bio, socials) => impl().setProfile(String(e || '').toLowerCase(), a, bio, socials),
|
||||||
touchSeen: e => impl().touchSeen(String(e || '').toLowerCase()),
|
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),
|
setChatAvailable: (e, v) => impl().setChatAvailable(String(e || '').toLowerCase(), v),
|
||||||
getMutes: e => impl().getMutes(String(e || '').toLowerCase()),
|
getMutes: e => impl().getMutes(String(e || '').toLowerCase()),
|
||||||
setMute: (o, t, m) => impl().setMute(String(o || '').toLowerCase(), String(t || '').toLowerCase(), m),
|
setMute: (o, t, m) => impl().setMute(String(o || '').toLowerCase(), String(t || '').toLowerCase(), m),
|
||||||
|
|||||||
+109
@@ -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 };
|
||||||
+28
-5
@@ -9,6 +9,11 @@
|
|||||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||||
<style>
|
<style>
|
||||||
.adm-table{width:100%;table-layout:auto}
|
.adm-table{width:100%;table-layout:auto}
|
||||||
|
.adm-table th{cursor:pointer;user-select:none;white-space:nowrap}
|
||||||
|
.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.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 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 th,.adm-table td{padding:8px 10px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line);font-size:13.5px}
|
||||||
@@ -228,6 +233,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pane" id="pane-members" hidden>
|
<div class="pane" id="pane-members" hidden>
|
||||||
|
<div class="card" id="memSearchCard">
|
||||||
|
<div class="card-head"><h3>Find a member</h3><span class="sub">email, @username, member #, share code or wallet address</span></div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap"><input id="memSearch" placeholder="jim@example.com, @teameb, #24, 0x1234…" style="flex:1;min-width:240px"><button type="button" class="btn small" id="memOpen">Open</button></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">
|
||||||
<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="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>
|
<div class="tablewrap"><table class="adm-table" id="tankWait"></table></div>
|
||||||
@@ -258,13 +281,13 @@
|
|||||||
<div class="card-head"><h3>Where visitors come from</h3><span class="sub" id="trfSub"></span></div>
|
<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>
|
<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 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>
|
||||||
<div class="tablewrap"><table class="adm-table" id="trfSources"></table></div>
|
<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>
|
||||||
<div class="grid c2">
|
<div class="grid c2">
|
||||||
<div class="card"><div class="card-head"><h3>Landing pages</h3><span class="sub">page views by page</span></div><div class="tablewrap"><table class="adm-table" id="trfPaths"></table></div></div>
|
<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><div class="tablewrap"><table class="adm-table" id="trfAngles"></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>
|
||||||
<div class="card"><div class="card-head"><h3>By day</h3><span class="sub">page views, signups</span></div><div class="tablewrap"><table class="adm-table" id="trfDaily"></table></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" 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>
|
<div class="card-head"><h3>Partner promo codes</h3><span class="sub">free ad credits for members who redeem a partner's code</span></div>
|
||||||
<p class="muted small" style="margin:0 0 10px">Give a site owner a code. Their members redeem it on a join link (<code>instantadpay.com/join/martbost?promo=CODE</code>) or in the "Have a promo code?" box on the Overview. One use per account; uses and the last redemptions are listed below.</p>
|
<p class="muted small" style="margin:0 0 10px">Give a site owner a code. Their members redeem it on a join link (<code>instantadpay.com/join/martbost?promo=CODE</code>) or in the "Have a promo code?" box on the Overview. One use per account; uses and the last redemptions are listed below.</p>
|
||||||
@@ -397,6 +420,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/assets/common.js?v=20260913a"></script>
|
<script src="/assets/common.js?v=20260913a"></script>
|
||||||
<script src="/assets/admin.js?v=20260912c"></script>
|
<script src="/assets/admin.js?v=20260913a"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+105
-3
@@ -291,13 +291,13 @@
|
|||||||
+ '<td class="mono small">' + (a.address ? esc(a.address.slice(0, 8) + '…' + a.address.slice(-6)) : '<span class="muted">none</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>' + (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="small muted when">' + when(a.created) + '</td>'
|
||||||
+ '<td class="act"><button class="btn small sec" data-spon="' + esc(a.email) + '" data-cur="' + esc(a.sponsorRef || '') + '">Sponsor</button></td></tr>').join('');
|
+ '<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);
|
$('memFilter').addEventListener('input', drawMembers);
|
||||||
document.addEventListener('click', async e => {
|
document.addEventListener('click', async e => {
|
||||||
const b = e.target.closest('[data-spon]'); if (!b) return;
|
const b = e.target.closest('[data-spon]'); if (!b) return;
|
||||||
const v = prompt('Sponsor for ' + b.dataset.spon + ' (username, share code, or member #). Leave blank to clear.', b.dataset.cur);
|
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) return;
|
if (v === null || v === undefined) return;
|
||||||
try {
|
try {
|
||||||
await api('/api/admin/members', { email: b.dataset.spon, sponsorRef: v.trim() }, 'PATCH');
|
await api('/api/admin/members', { email: b.dataset.spon, sponsorRef: v.trim() }, 'PATCH');
|
||||||
IAP.status('Sponsor updated.', 'ok');
|
IAP.status('Sponsor updated.', 'ok');
|
||||||
@@ -305,6 +305,108 @@
|
|||||||
} catch (err) { IAP.status(err.message, 'bad'); }
|
} 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;
|
||||||
|
$('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)' : '-'],
|
||||||
|
['Legacy', d.legacy ? esc(d.legacy.brand) + ' ' + d.legacy.seg + (d.legacy.grant ? ' · ' + d.legacy.grant.credits + ' credits granted ' + when(d.legacy.grant.at) : ' · not granted') : '<span class="muted">not on the legacy list</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; }); }
|
||||||
|
$('memOpen').addEventListener('click', () => { const q = $('memSearch').value.trim(); if (q) openMember(q); });
|
||||||
|
$('memSearch').addEventListener('keydown', e => { if (e.key === 'Enter') $('memOpen').click(); });
|
||||||
|
$('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' : '';
|
||||||
|
});
|
||||||
|
|
||||||
// ── reports + burns ──
|
// ── reports + burns ──
|
||||||
// ── profit and loss ──
|
// ── profit and loss ──
|
||||||
let pnlDays = 30;
|
let pnlDays = 30;
|
||||||
|
|||||||
+3
-1
@@ -1699,7 +1699,9 @@
|
|||||||
if (box) box.innerHTML = '<div class="done-big"><span class="tick">✓</span><b>Ads done for today</b><span class="muted small">Today\'s set is viewed and claimed. Fresh ads tomorrow.</span>'
|
if (box) box.innerHTML = '<div class="done-big"><span class="tick">✓</span><b>Ads done for today</b><span class="muted small">Today\'s set is viewed and claimed. Fresh ads tomorrow.</span>'
|
||||||
+ '<div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center;margin-top:14px">'
|
+ '<div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center;margin-top:14px">'
|
||||||
+ (st.visitsLeft ? '<button type="button" class="btn small" data-earnnext="visits">Keep earning: ' + st.visitsLeft + ' verified visit' + (st.visitsLeft === 1 ? '' : 's') + ' left today</button>' : '')
|
+ (st.visitsLeft ? '<button type="button" class="btn small" data-earnnext="visits">Keep earning: ' + st.visitsLeft + ' verified visit' + (st.visitsLeft === 1 ? '' : 's') + ' left today</button>' : '')
|
||||||
+ '<button type="button" class="btn sec small" data-earnnext="campaigns">Launch a campaign with your ' + (st.earnedAvailable != null ? st.earnedAvailable : st.earned) + ' credits</button></div></div>';
|
+ ((st.earnedAvailable != null ? st.earnedAvailable : st.earned) > 0
|
||||||
|
? '<button type="button" class="btn sec small" data-earnnext="campaigns">Launch a campaign with your ' + (st.earnedAvailable != null ? st.earnedAvailable : st.earned) + ' credits</button>'
|
||||||
|
: '<button type="button" class="btn sec small" data-earnnext="campaigns">All your credits are working in live campaigns</button>') + '</div></div>';
|
||||||
if (box) box.querySelectorAll('[data-earnnext]').forEach(b => b.addEventListener('click', () => { if (b.dataset.earnnext === 'campaigns') setPane('campaigns'); else setEarnSub('visits'); }));
|
if (box) box.querySelectorAll('[data-earnnext]').forEach(b => b.addEventListener('click', () => { if (b.dataset.earnnext === 'campaigns') setPane('campaigns'); else setEarnSub('visits'); }));
|
||||||
if ($('earnStartBtn')) $('earnStartBtn').hidden = true;
|
if ($('earnStartBtn')) $('earnStartBtn').hidden = true;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+1
-1
@@ -915,7 +915,7 @@
|
|||||||
<script src="/assets/common.js?v=20260913a"></script>
|
<script src="/assets/common.js?v=20260913a"></script>
|
||||||
<script src="/assets/wallet.js?v=20260911a"></script>
|
<script src="/assets/wallet.js?v=20260911a"></script>
|
||||||
<script src="/assets/promo.js?v=20260911a"></script>
|
<script src="/assets/promo.js?v=20260911a"></script>
|
||||||
<script src="/assets/my.js?v=20260912i"></script>
|
<script src="/assets/my.js?v=20260913a"></script>
|
||||||
<script src="/assets/chat.js?v=20260907l"></script>
|
<script src="/assets/chat.js?v=20260907l"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ const tank = require('./tank'); // holding tank: unsponsored free members, a
|
|||||||
const legacy = require('./legacy'); // Faucet Wave / Tier One Ads bridge: welcome-back credits for listed emails
|
const legacy = require('./legacy'); // Faucet Wave / Tier One Ads bridge: welcome-back credits for listed emails
|
||||||
const traffic = require('./traffic'); // public page views by referring domain (admin Traffic tab)
|
const traffic = require('./traffic'); // public page views by referring domain (admin Traffic tab)
|
||||||
const promos = require('./promos'); // partner promo codes -> free ad credits (link ?promo=CODE or the dashboard box)
|
const promos = require('./promos'); // partner promo codes -> free ad credits (link ?promo=CODE or the dashboard box)
|
||||||
const blog = require('./blog'); // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
|
const blog = require('./blog');
|
||||||
|
const adminMember = require('./adminmember'); // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
|
||||||
const TRAFFIC_PAGES = new Set(['/', '/ledger', '/contract', '/shorts', '/plays', '/wallets', '/launch', '/partners', '/earning', '/blog']);
|
const TRAFFIC_PAGES = new Set(['/', '/ledger', '/contract', '/shorts', '/plays', '/wallets', '/launch', '/partners', '/earning', '/blog']);
|
||||||
let tankWaitCache = null; // dashboard: who is waiting for a sponsor (refreshed every minute)
|
let tankWaitCache = null; // dashboard: who is waiting for a sponsor (refreshed every minute)
|
||||||
const geo = require('./geo'); // viewer country -> tier (DB-IP lite), for campaign targeting
|
const geo = require('./geo'); // viewer country -> tier (DB-IP lite), for campaign targeting
|
||||||
@@ -330,6 +331,7 @@ async function boot() {
|
|||||||
traffic.init({ dataDir: DATA_DIR });
|
traffic.init({ dataDir: DATA_DIR });
|
||||||
promos.init({ dataDir: DATA_DIR });
|
promos.init({ dataDir: DATA_DIR });
|
||||||
blog.init({ dataDir: DATA_DIR });
|
blog.init({ dataDir: DATA_DIR });
|
||||||
|
adminMember.init({ accounts, ads, chain, tank, legacy, promos, messages, dataDir: DATA_DIR });
|
||||||
setInterval(() => tankNotifyTick().catch(e => console.error('tank notify', e.message)), 15 * 60 * 1000); // new tank arrivals -> Telegram
|
setInterval(() => tankNotifyTick().catch(e => console.error('tank notify', e.message)), 15 * 60 * 1000); // new tank arrivals -> Telegram
|
||||||
geo.init({ dataDir: DATA_DIR }).catch(e => console.error('geo init', e.message));
|
geo.init({ dataDir: DATA_DIR }).catch(e => console.error('geo init', e.message));
|
||||||
setInterval(() => geo.refresh().catch(e => console.error('geo refresh', e.message)), 24 * 3600 * 1000); // monthly file, checked daily
|
setInterval(() => geo.refresh().catch(e => console.error('geo refresh', e.message)), 24 * 3600 * 1000); // monthly file, checked daily
|
||||||
@@ -1096,6 +1098,55 @@ const server = http.createServer(async (req, res) => {
|
|||||||
await ads.addEarned(s.email, g.credits);
|
await ads.addEarned(s.email, g.credits);
|
||||||
return json(res, 200, { ok: true, credits: g.credits, code: g.code, partner: g.partner });
|
return json(res, 200, { ok: true, credits: g.credits, code: g.code, partner: g.partner });
|
||||||
}
|
}
|
||||||
|
// -- admin: member card. GET ?q= resolves email / @username / #id / share code / wallet;
|
||||||
|
// PATCH edits username, sponsor, main wallet or grants credits; DELETE removes a free account (2026-09-13)
|
||||||
|
if (p === '/api/admin/member' && req.method === 'GET') {
|
||||||
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
|
const q = u.searchParams.get('q') || u.searchParams.get('email') || '';
|
||||||
|
const a = await adminMember.resolve(q);
|
||||||
|
if (!a) return json(res, 404, { error: 'No member matches "' + q.slice(0, 60) + '".' });
|
||||||
|
return json(res, 200, await adminMember.view(a.email));
|
||||||
|
}
|
||||||
|
if (p === '/api/admin/member' && req.method === 'PATCH') {
|
||||||
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
|
const b = await readBody(req);
|
||||||
|
const e = String(b.email || '').trim().toLowerCase(); const acct = e && await accounts.byEmail(e);
|
||||||
|
if (!acct) return json(res, 404, { error: 'No such member.' });
|
||||||
|
if (b.username !== undefined) {
|
||||||
|
const un = String(b.username || '').trim().toLowerCase();
|
||||||
|
if (!/^[a-z0-9_]{3,20}$/.test(un)) return json(res, 400, { error: 'Username: 3 to 20 letters, numbers or underscore.' });
|
||||||
|
const r = await accounts.setUsername(e, un); if (r.error) return json(res, 400, r);
|
||||||
|
console.log('admin username', e, un);
|
||||||
|
}
|
||||||
|
if (b.sponsorRef !== undefined) { const r = await accounts.setSponsorRef(e, String(b.sponsorRef || '').trim()); if (r.error) return json(res, 400, r); console.log('admin sponsor', e, String(b.sponsorRef || '').trim()); }
|
||||||
|
if (b.address !== undefined) {
|
||||||
|
const a = String(b.address || '').trim().toLowerCase();
|
||||||
|
if (a && !/^0x[0-9a-f]{40}$/.test(a)) return json(res, 400, { error: 'That is not a wallet address.' });
|
||||||
|
const r = await accounts.adminSetAddress(e, a || null); if (r.error) return json(res, 400, r);
|
||||||
|
let mid = 0; if (a) { try { mid = Number(await chain.memberIdByAccount(a)) || 0; } catch (x) {} }
|
||||||
|
await accounts.setMemberId(e, mid);
|
||||||
|
if (db.enabled()) await db.q('UPDATE sessions SET address=?, member_id=? WHERE email=?', [a || null, mid || null, e]).catch(() => {});
|
||||||
|
console.log('admin wallet swap', e, a || '(none)', 'member', mid);
|
||||||
|
}
|
||||||
|
if (b.grantCredits !== undefined) {
|
||||||
|
const n = Math.round(Number(b.grantCredits));
|
||||||
|
if (!(n > 0) || n > 100000) return json(res, 400, { error: 'Credits: a whole number from 1 to 100,000.' });
|
||||||
|
await ads.addEarned(e, n);
|
||||||
|
console.log('admin credits', e, n, String(b.note || '').slice(0, 100));
|
||||||
|
}
|
||||||
|
return json(res, 200, await adminMember.view(e));
|
||||||
|
}
|
||||||
|
if (p === '/api/admin/member' && req.method === 'DELETE') {
|
||||||
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
|
const e = String(u.searchParams.get('email') || '').trim().toLowerCase(); const acct = e && await accounts.byEmail(e);
|
||||||
|
if (!acct) return json(res, 404, { error: 'No such member.' });
|
||||||
|
if (acct.memberId) return json(res, 400, { error: 'Member #' + acct.memberId + ' is registered on-chain and cannot be deleted.' });
|
||||||
|
const pos = await accounts.positions(e); if (pos.some(x => x.memberId)) return json(res, 400, { error: 'This account owns a registered position and cannot be deleted.' });
|
||||||
|
const r = await accounts.removeAccount(e); if (r.error) return json(res, 400, r);
|
||||||
|
if (db.enabled()) await db.q('DELETE FROM sessions WHERE email=?', [e]).catch(() => {});
|
||||||
|
console.log('admin removed account', e);
|
||||||
|
return json(res, 200, { ok: true });
|
||||||
|
}
|
||||||
// -- admin: blog (list all incl. drafts, save/create, delete) (Marty, 2026-09-12)
|
// -- admin: blog (list all incl. drafts, save/create, delete) (Marty, 2026-09-12)
|
||||||
if (p === '/api/admin/blog' && req.method === 'GET') {
|
if (p === '/api/admin/blog' && req.method === 'GET') {
|
||||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
|
|||||||
Reference in New Issue
Block a user