Wall ownership ladder: positions 2 and 3 become the member's own at 2 / 5 qualifying buyers

- accounts.wall_offers (JSON, up to 2 offers: label, https link, banner) set from
  Profile > Your wall; upload supported; locks show the buyer threshold.
- /api/wall assembles: position 1 = own line banner; positions 2-3 = own offer
  when unlocked and set, else upline banners (only uplines with a live banner),
  else house ads. Response carries unlocked + buyerCount; wall labels show
  "this wall" / "their line" / "InstantAdPay".
- Chatbot canned answer + facts, follow-up email 7 mention the ladder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-09 15:31:45 -05:00
parent 8b25361eef
commit 2ac6549171
21 changed files with 192 additions and 69 deletions
+48 -14
View File
@@ -82,6 +82,14 @@ async function uplineSlides(email, depth = 3) {
}
return out;
}
// Wall ownership ladder: position 1 is always the member's own line banner;
// positions 2 and 3 become theirs at 2 and 5 qualifying buyers (the same
// thresholds that open payout levels 2 and 3). Until then, or while an unlocked
// slot is empty, the slot shows an upline's banner, then a house ad.
const wallUnlockedFor = bc => (bc >= 5 ? 3 : bc >= 2 ? 2 : 1);
function parseWallOffers(a) {
try { const v = JSON.parse((a && a.wallOffers) || '[]'); return Array.isArray(v) ? v.slice(0, 2) : []; } catch (e) { return []; }
}
// admin fallback ads for wall positions 2 & 3 when a member has no upline.
// Configurable by dropping data/admin-wall-ads.json ([{name,targetUrl,bannerUrl}]).
function getAdminWallAds() {
@@ -681,6 +689,7 @@ const server = http.createServer(async (req, res) => {
// profile + line-banner fields so the Profile pane repopulates on reload (were being saved but not returned)
avatarUrl: (acct && acct.avatarUrl) || null, bio: (acct && acct.bio) || null,
socials: (acct && acct.socials) || null,
wallOffers: parseWallOffers(acct),
lineBannerUrl: (acct && acct.lineBannerUrl) || null, lineTargetUrl: (acct && acct.lineTargetUrl) || null };
if (memberId) {
try {
@@ -772,6 +781,7 @@ const server = http.createServer(async (req, res) => {
status: r.address ? 'wallet linked' : 'joined free'
}));
out.isAdmin = !!(ADMIN_EMAIL && out.email && String(out.email).toLowerCase() === ADMIN_EMAIL); // shows the Admin link
out.wallUnlocked = wallUnlockedFor(out.buyerCount || 0); // how many wall positions are the member's own
return json(res, 200, out);
}
if (p === '/api/my/profile' && req.method === 'POST') {
@@ -1129,6 +1139,25 @@ const server = http.createServer(async (req, res) => {
const r = await accounts.setProfile(s.email, avatar, bio, socials);
return json(res, r.error ? 400 : 200, r);
}
// -- wall positions 2 & 3: the member's own offers, unlocked at 2 / 5 qualifying buyers
if (p === '/api/my/wall-offers' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const b = await readBody(req);
const src = Array.isArray(b.offers) ? b.offers.slice(0, 2) : [];
const out = [];
for (let i = 0; i < 2; i++) {
const o = src[i] || {};
const targetUrl = String(o.targetUrl || '').trim();
const bannerUrl = String(o.bannerUrl || '').trim();
const title = String(o.title || '').trim().slice(0, 60);
if (targetUrl && !/^https:\/\/[^\s]+$/i.test(targetUrl)) return json(res, 400, { error: 'Position ' + (i + 2) + ': the link must start with https://' });
if (bannerUrl && !/^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|https:\/\/[^\s]+)$/i.test(bannerUrl)) return json(res, 400, { error: 'Position ' + (i + 2) + ': banner must be an uploaded image or an https image URL.' });
out.push(targetUrl ? { title: title || null, bannerUrl: bannerUrl || null, targetUrl } : null);
}
const r = await accounts.setWallOffers(s.email, out.some(Boolean) ? JSON.stringify(out) : null);
return json(res, r.error ? 400 : 200, r);
}
// -- line banner: the member's viral slot on welcome tours + their wall
if (p === '/api/my/linebanner' && req.method === 'POST') {
const s = await auth.fromRequest(req);
@@ -1175,31 +1204,36 @@ const server = http.createServer(async (req, res) => {
let a = await accounts.byUsername(tok);
if (!a) a = await accounts.byCode(tok);
if (!a) return json(res, 404, { error: 'No wall under that name.' });
const ladder = [a, ...await uplineSlides(a.email, 2)]
const ownName = a.username ? '@' + a.username : a.memberId ? 'member #' + a.memberId : 'a member';
let bc = 0;
if (a.memberId) { try { bc = (await chain.member(a.memberId)).buyerCount || 0; } catch (e) {} }
const unlocked = wallUnlockedFor(bc);
const offers = parseWallOffers(a);
// uplines with a live banner, in order (an upline with nothing set is skipped, not shown empty)
const ups = (await uplineSlides(a.email, 2)).filter(x => x.lineTargetUrl)
.map(x => ({ name: x.username ? '@' + x.username : x.memberId ? 'member #' + x.memberId : 'a member',
bannerUrl: x.lineBannerUrl || null, targetUrl: x.lineTargetUrl || null }));
// fill positions 2 & 3 with admin ads when there isn't enough upline, so the wall is never sparse
const adAds = getAdminWallAds();
let ai = 0;
while (ladder.length < 3 && adAds.length) {
const ad = adAds[ai++ % adAds.length];
ladder.push({ name: ad.name || 'InstantAdPay', bannerUrl: ad.bannerUrl || null, targetUrl: ad.targetUrl || 'https://instantadpay.com/', admin: true });
bannerUrl: x.lineBannerUrl || null, targetUrl: x.lineTargetUrl, upline: true }));
const adAds = getAdminWallAds(); let ai = 0;
const houseAd = () => { if (!adAds.length) return null; const ad = adAds[ai++ % adAds.length]; return { name: ad.name || 'InstantAdPay', bannerUrl: ad.bannerUrl || null, targetUrl: ad.targetUrl || 'https://instantadpay.com/', admin: true }; };
const ladder = [{ name: ownName, bannerUrl: a.lineBannerUrl || null, targetUrl: a.lineTargetUrl || null, own: true }];
for (let i = 1; i < 3; i++) {
const o = offers[i - 1];
if (i < unlocked && o && o.targetUrl) { ladder.push({ name: (o.title || ownName), bannerUrl: o.bannerUrl || null, targetUrl: o.targetUrl, own: true }); continue; }
const u = ups.shift(); if (u) { ladder.push(u); continue; }
const h = houseAd(); if (h) ladder.push(h);
}
const finalLadder = ladder.slice(0, 3);
// the wall owner's achievement badge (their highest reached tier)
let badge = null;
if (a.memberId) {
try {
const bc = (await chain.member(a.memberId)).buyerCount || 0;
const t = bc >= 5 ? ['nexus', 'Nexus'] : bc >= 2 ? ['circuit', 'Circuit'] : bc >= 1 ? ['surge', 'Surge'] : ['spark', 'Spark'];
badge = { img: '/badges/badge-' + t[0] + '.jpg?v=2', label: t[1] };
} catch (e) {}
const t = bc >= 5 ? ['nexus', 'Nexus'] : bc >= 2 ? ['circuit', 'Circuit'] : bc >= 1 ? ['surge', 'Surge'] : ['spark', 'Spark'];
badge = { img: '/badges/badge-' + t[0] + '.jpg?v=2', label: t[1] };
}
const joinPath = '/join/' + (a.username || a.code);
let socials = null; try { socials = a.socials ? JSON.parse(a.socials) : null; } catch (e) {}
return json(res, 200, { name: a.username ? '@' + a.username : 'member #' + (a.memberId || 0),
avatarUrl: a.avatarUrl || null, bio: a.bio || null, socials, badge,
joinUrl: joinPath, qrUrl: '/api/qr?d=' + encodeURIComponent('https://instantadpay.com' + joinPath), ladder: finalLadder });
joinUrl: joinPath, qrUrl: '/api/qr?d=' + encodeURIComponent('https://instantadpay.com' + joinPath), ladder: finalLadder, unlocked, buyerCount: bc });
}
// -- watch-to-earn video ads: serve one, then reward a server-clock-verified watch
if (p === '/api/my/videos' && req.method === 'GET') {