Member identity, promo tools, back-office color, seeded inventory

Real people, not numbers: usernames (unique, profile pane to set them),
shown across the ledger, activity, rosters, and the sidebar chip; vanity
invite links (/join/<username>) with late chain binding intact. Promo tools
pane ships Branded Voice share posts and an email swipe personalized with
each member's link. The earn viewer excludes a member's own campaigns, so
nobody earns from their own spend. Back office gains the cyan/violet/amber
accent family over the green base. Three house campaigns seeded so every
surface shows live inventory. Assets v=20260905i.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-05 06:28:17 -05:00
parent 28ed925443
commit e22021c266
11 changed files with 257 additions and 53 deletions
+34 -9
View File
@@ -39,7 +39,7 @@ const emailCodes = new Map();
const earnTokens = new Map();
async function boot() {
await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode)
chain.init({ onEvent: ev => pushFeed(ev) });
chain.init({ onEvent: ev => attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)) });
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
accounts.init({ dataDir: DATA_DIR });
ads.init({ dataDir: DATA_DIR, chain });
@@ -101,6 +101,18 @@ function isAdmin(req) {
const h = req.headers.authorization || '';
return h === 'Bearer ' + ADMIN_PASSWORD;
}
// attach a memberId->username map to events so activity shows real people
async function attachNames(evts) {
try {
const ids = [];
for (const ev of evts)
for (const k of ['id', 'buyerId', 'recipientId', 'skippedId', 'sponsorId', 'newBuyerId', 'toId', 'memberId'])
if (ev[k]) ids.push(ev[k]);
const names = await accounts.namesForMembers(ids);
if (!Object.keys(names).length) return evts;
return evts.map(ev => Object.assign({}, ev, { names }));
} catch (e) { return evts; }
}
// A sponsor token is a numeric chain id or a site share code. Codes resolve
// to the referrer's CURRENT chain id, so activation any time before the
// referral's first purchase still locks the line to them.
@@ -108,7 +120,8 @@ async function resolveSponsorToken(tok) {
const t = String(tok || '').trim().toLowerCase();
if (!t) return 0;
if (/^\d+$/.test(t)) return Number(t);
const acct = await accounts.byCode(t);
let acct = await accounts.byCode(t);
if (!acct) acct = await accounts.byUsername(t); // vanity links: /join/<username>
if (!acct || !acct.address) return 0;
try { return await chain.memberIdByAccount(acct.address); } catch (e) { return 0; }
}
@@ -144,7 +157,7 @@ const server = http.createServer(async (req, res) => {
// -- join links: /join/<memberId or share code> — first-touch cookie.
// Codes resolve LATE (at buy time) to whatever chain id the referrer
// has by then, so free members refer from day one.
let m = /^\/join\/([A-Za-z0-9]{1,16})$/.exec(p);
let m = /^\/join\/([A-Za-z0-9_]{1,20})$/.exec(p);
if (m && req.method === 'GET') {
const tok = m[1].toLowerCase();
const cookies = parseCookies(req);
@@ -167,7 +180,7 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { products: await chain.catalog() });
}
if (p === '/api/feed' && req.method === 'GET') {
return json(res, 200, { events: chain.recentEvents(Number(u.searchParams.get('n')) || 100) });
return json(res, 200, { events: await attachNames(chain.recentEvents(Number(u.searchParams.get('n')) || 100)) });
}
if (p === '/api/feed/live' && req.method === 'GET') {
res.writeHead(200, baseHeaders({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' }));
@@ -297,8 +310,10 @@ const server = http.createServer(async (req, res) => {
const memberId = await auth.refreshMemberId(s);
const acct = (s.email && await accounts.byEmail(s.email)) || (s.address && await accounts.byAddress(s.address)) || null;
const sponsorId = await resolveSponsorToken((acct && acct.sponsorRef) || parseCookies(req)['iap.sponsor']);
if (memberId && acct && acct.memberId !== memberId) accounts.setMemberId(acct.email, memberId).catch(() => {});
const out = { signedIn: true, email: s.email || (acct && acct.email) || null,
address: s.address || (acct && acct.address) || null, memberId,
username: (acct && acct.username) || null,
refCode: (acct && acct.code) || null, sponsorId };
if (memberId) {
try {
@@ -316,8 +331,10 @@ const server = http.createServer(async (req, res) => {
if (!s) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s);
const acct = (s.email && await accounts.byEmail(s.email)) || (s.address && await accounts.byAddress(s.address)) || null;
if (memberId && acct && acct.memberId !== memberId) accounts.setMemberId(acct.email, memberId).catch(() => {});
const out = { memberId, email: s.email || (acct && acct.email) || null,
address: s.address || (acct && acct.address) || null,
username: (acct && acct.username) || null,
refCode: (acct && acct.code) || null, credits: 0, buyerCount: 0,
earnedWei: '0', earnCount: 0, referrals: [], welcomeCredits: 0 };
if (out.email) out.welcomeCredits = await ads.grantWelcome(out.email); // idempotent lazy grant
@@ -342,12 +359,19 @@ const server = http.createServer(async (req, res) => {
if (memberId) refs.push(String(memberId));
const joined = await accounts.listByReferrer(refs);
out.referrals = joined.map(r => ({
email: r.email.replace(/^(.).*(@.*)$/, '$1***$2'), // privacy mask
name: r.username || r.email.replace(/^(.).*(@.*)$/, '$1***$2'), // username, else privacy mask
joined: r.created,
status: r.address ? 'wallet linked' : 'joined free'
}));
return json(res, 200, out);
}
if (p === '/api/my/profile' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const b = await readBody(req);
const r = await accounts.setUsername(s.email, b.username);
return json(res, r.error ? 400 : 200, r);
}
// -- earn credits by viewing ads (attention-gated daily claim)
if (p === '/api/my/earn' && req.method === 'GET') {
const s = await auth.fromRequest(req);
@@ -364,7 +388,8 @@ const server = http.createServer(async (req, res) => {
const status = await ads.viewStatus(s.email);
if (status.views >= status.target || status.claimed) return json(res, 200, { ad: null, status });
const type = String(u.searchParams.get('type') || 'banner');
const ad = await ads.serve(type === 'text' ? 'text' : 'banner');
// members never see (or earn from) their own campaigns in the viewer
const ad = await ads.serve(type === 'text' ? 'text' : 'banner', { excludeEmail: s.email });
if (!ad) return json(res, 200, { ad: null, status });
const token = crypto.randomBytes(16).toString('hex');
earnTokens.set(s.email, { token, ts: Date.now() });
@@ -397,9 +422,9 @@ const server = http.createServer(async (req, res) => {
const evs = chain.recentEvents(600);
return json(res, 200, {
memberId: id,
earnings: evs.filter(e => (e.type === 'TierPaid' && e.recipientId === id) || (e.type === 'AwardPaid' && e.toId === id)),
purchases: evs.filter(e => e.type === 'Purchase' && e.buyerId === id),
referrals: evs.filter(e => (e.type === 'MemberActivated' && e.sponsorId === id) || (e.type === 'BuyerCounted' && e.sponsorId === id))
earnings: await attachNames(evs.filter(e => (e.type === 'TierPaid' && e.recipientId === id) || (e.type === 'AwardPaid' && e.toId === id))),
purchases: await attachNames(evs.filter(e => e.type === 'Purchase' && e.buyerId === id)),
referrals: await attachNames(evs.filter(e => (e.type === 'MemberActivated' && e.sponsorId === id) || (e.type === 'BuyerCounted' && e.sponsorId === id)))
});
}