Wall page: mint QR, achievement badge, admin-ad backfill, join gated on views

- QR is mint-green (still high-contrast for reliable scanning).
- The wall shows the owner's achievement badge next to their avatar.
- Positions 2 & 3 fill with upline line-banners, then ADMIN ADS when there's no
  upline (configurable via data/admin-wall-ads.json; sensible default) so the
  wall is never sparse.
- "Join free through this wall" is disabled until the visitor has viewed every
  ad on the wall, with a "X/Y viewed" prompt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-07 08:35:44 -05:00
parent a4119d652d
commit 0dbd99f69e
11 changed files with 59 additions and 21 deletions
+2
View File
@@ -376,6 +376,8 @@ textarea{resize:vertical;font:inherit}
/* ── bio header (wall/profile page) ── */ /* ── bio header (wall/profile page) ── */
.bio-head{display:flex;gap:22px;align-items:center;flex-wrap:wrap;padding:8px 0 4px} .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-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-meta{flex:1;min-width:220px}
.bio-socials{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px} .bio-socials{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
.bio-socials a{font-size:12px;font-weight:700;color:var(--mint);border:1px solid var(--line-strong); .bio-socials a{font-size:12px;font-weight:700;color:var(--mint);border:1px solid var(--line-strong);
+13 -2
View File
@@ -16,6 +16,7 @@
title.innerHTML = safeName; title.innerHTML = safeName;
IAP.$('bioHead').hidden = false; IAP.$('bioHead').hidden = false;
if (w.avatarUrl) { const av = IAP.$('bioAvatar'); av.src = w.avatarUrl; av.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 InstantAdPay — join through this page and you\'re in my line.'; IAP.$('bioText').textContent = w.bio || 'Building a team on InstantAdPay — join through this page and you\'re in my line.';
if (w.qrUrl) IAP.$('bioQr').src = w.qrUrl; if (w.qrUrl) IAP.$('bioQr').src = w.qrUrl;
// social links // social links
@@ -27,7 +28,6 @@
sc.innerHTML = links.join(''); sc.innerHTML = links.join('');
} }
IAP.$('wallCtaHead').textContent = 'Join ' + w.name + '’s line'; IAP.$('wallCtaHead').textContent = 'Join ' + w.name + '’s line';
IAP.$('wallJoin').href = w.joinUrl;
// per-wall viewed-position memory (survives revisits; anonymous-friendly) // per-wall viewed-position memory (survives revisits; anonymous-friendly)
const VKEY = 'iap-wall-' + name; const VKEY = 'iap-wall-' + name;
let viewed = {}; let viewed = {};
@@ -35,6 +35,16 @@
const DWELL = 10; const DWELL = 10;
const grid = IAP.$('wallGrid'); const grid = IAP.$('wallGrid');
grid.innerHTML = ''; 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) => { w.ladder.forEach((m, i) => {
const d = document.createElement('div'); const d = document.createElement('div');
d.className = 'wall-card'; d.className = 'wall-card';
@@ -64,11 +74,12 @@
clearInterval(t); clearInterval(t);
viewed[i] = 1; viewed[i] = 1;
try { localStorage.setItem(VKEY, JSON.stringify(viewed)); } catch (e) {} try { localStorage.setItem(VKEY, JSON.stringify(viewed)); } catch (e) {}
done(); done(); updateGate();
}, 1000); }, 1000);
}); });
act.appendChild(btn); act.appendChild(btn);
} }
grid.appendChild(d); grid.appendChild(d);
}); });
updateGate();
})(); })();
+2 -2
View File
@@ -16,7 +16,7 @@
<meta name="twitter:image" content="https://instantadpay.com/banners/iap-hero-1200x630.png"> <meta name="twitter:image" content="https://instantadpay.com/banners/iap-hero-1200x630.png">
<meta name="theme-color" content="#043b2f"> <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="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260906m"> <link rel="stylesheet" href="/assets/site.css?v=20260907s">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
@@ -140,7 +140,7 @@
<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="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> </footer>
</div> </div>
<script src="/assets/common.js?v=20260907n"></script> <script src="/assets/common.js?v=20260907s"></script>
<script src="/assets/contract.js?v=20260906m"></script> <script src="/assets/contract.js?v=20260906m"></script>
<script src="/assets/chat.js?v=20260906m"></script> <script src="/assets/chat.js?v=20260906m"></script>
</body> </body>
+2 -2
View File
@@ -19,7 +19,7 @@
<meta name="twitter:image" content="https://instantadpay.com/banners/iap-hero-1200x630.png"> <meta name="twitter:image" content="https://instantadpay.com/banners/iap-hero-1200x630.png">
<meta name="theme-color" content="#043b2f"> <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="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260906m"> <link rel="stylesheet" href="/assets/site.css?v=20260907s">
</head> </head>
<body> <body>
@@ -437,7 +437,7 @@
</div> </div>
</section> </section>
<script src="/assets/common.js?v=20260907n"></script> <script src="/assets/common.js?v=20260907s"></script>
<script src="/assets/wallet.js?v=20260907q"></script> <script src="/assets/wallet.js?v=20260907q"></script>
<script src="/assets/home.js?v=20260906m"></script> <script src="/assets/home.js?v=20260906m"></script>
<script src="/assets/chat.js?v=20260906m"></script> <script src="/assets/chat.js?v=20260906m"></script>
+2 -2
View File
@@ -16,7 +16,7 @@
<meta name="twitter:image" content="https://instantadpay.com/banners/iap-hero-1200x630.png"> <meta name="twitter:image" content="https://instantadpay.com/banners/iap-hero-1200x630.png">
<meta name="theme-color" content="#043b2f"> <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="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260906m"> <link rel="stylesheet" href="/assets/site.css?v=20260907s">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
@@ -36,7 +36,7 @@
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div> <div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
</footer> </footer>
</div> </div>
<script src="/assets/common.js?v=20260907n"></script> <script src="/assets/common.js?v=20260907s"></script>
<script src="/assets/ledger.js?v=20260906m"></script> <script src="/assets/ledger.js?v=20260906m"></script>
<script src="/assets/chat.js?v=20260906m"></script> <script src="/assets/chat.js?v=20260906m"></script>
</body> </body>
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Member area | InstantAdPay</title> <title>Member area | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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=20260907o"> <link rel="stylesheet" href="/assets/site.css?v=20260907s">
</head> </head>
<body class="bo-body"> <body class="bo-body">
@@ -664,7 +664,7 @@
</div> </div>
</div> </div>
</div> </div>
<script src="/assets/common.js?v=20260907n"></script> <script src="/assets/common.js?v=20260907s"></script>
<script src="/assets/wallet.js?v=20260907q"></script> <script src="/assets/wallet.js?v=20260907q"></script>
<script src="/assets/my.js?v=20260907r"></script> <script src="/assets/my.js?v=20260907r"></script>
<script src="/assets/chat.js?v=20260907l"></script> <script src="/assets/chat.js?v=20260907l"></script>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Shorts | InstantAdPay</title> <title>Shorts | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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=20260906m"> <link rel="stylesheet" href="/assets/site.css?v=20260907s">
<style> <style>
html,body{height:100%;margin:0;overflow:hidden;background:#000} html,body{height:100%;margin:0;overflow:hidden;background:#000}
.sh{position:fixed;inset:0;display:flex;flex-direction:column;background:#000;color:var(--ink,#e8fff7)} .sh{position:fixed;inset:0;display:flex;flex-direction:column;background:#000;color:var(--ink,#e8fff7)}
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Transaction | InstantAdPay</title> <title>Transaction | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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=20260906m"> <link rel="stylesheet" href="/assets/site.css?v=20260907s">
</head> </head>
<body> <body>
<div id="nav"></div> <div id="nav"></div>
@@ -33,7 +33,7 @@
<p><a href="/ledger">← Back to the live ledger</a> · <a href="/contract">Read the contract review</a></p> <p><a href="/ledger">← Back to the live ledger</a> · <a href="/contract">Read the contract review</a></p>
</div> </div>
</section> </section>
<script src="/assets/common.js?v=20260907n"></script> <script src="/assets/common.js?v=20260907s"></script>
<script src="/assets/tx.js?v=20260906m"></script> <script src="/assets/tx.js?v=20260906m"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Viewing ad — InstantAdPay</title> <title>Viewing ad — InstantAdPay</title>
<link rel="stylesheet" href="/assets/site.css?v=20260906m"> <link rel="stylesheet" href="/assets/site.css?v=20260907s">
<style> <style>
html,body{height:100%;margin:0;overflow:hidden} html,body{height:100%;margin:0;overflow:hidden}
.vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)} .vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)}
+5 -3
View File
@@ -5,7 +5,7 @@
<title>Banner wall | InstantAdPay</title> <title>Banner wall | InstantAdPay</title>
<!--OG--> <!--OG-->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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=20260906m"> <link rel="stylesheet" href="/assets/site.css?v=20260907s">
</head> </head>
<body> <body>
<div id="nav"></div> <div id="nav"></div>
@@ -13,6 +13,7 @@
<div class="wrap" style="max-width:920px"> <div class="wrap" style="max-width:920px">
<div class="bio-head" id="bioHead" hidden> <div class="bio-head" id="bioHead" hidden>
<img id="bioAvatar" class="bio-avatar" alt="" hidden> <img id="bioAvatar" class="bio-avatar" alt="" hidden>
<img id="bioBadge" class="bio-badge" alt="achievement badge" hidden>
<div class="bio-meta"> <div class="bio-meta">
<p class="eyebrow">Member profile</p> <p class="eyebrow">Member profile</p>
<h2 id="wallTitle" style="margin:.1em 0">Loading…</h2> <h2 id="wallTitle" style="margin:.1em 0">Loading…</h2>
@@ -31,12 +32,13 @@
<h3 id="wallCtaHead">Join this line</h3> <h3 id="wallCtaHead">Join this line</h3>
<p class="muted small">Free to join with just an email. Your wallet only comes out if you buy — <p class="muted small">Free to join with just an email. Your wallet only comes out if you buy —
and payments go straight to member wallets, never through an admin.</p> and payments go straight to member wallets, never through an admin.</p>
<p id="wallGate" class="small" style="color:var(--amber);font-weight:600" hidden></p>
<p><a class="btn" id="wallJoin">Join free through this wall</a></p> <p><a class="btn" id="wallJoin">Join free through this wall</a></p>
<p class="small muted"><a href="/ledger">Watch the live ledger</a> · <a href="/contract">Read the contract</a></p> <p class="small muted"><a href="/ledger">Watch the live ledger</a> · <a href="/contract">Read the contract</a></p>
</div> </div>
</div> </div>
</section> </section>
<script src="/assets/common.js?v=20260907n"></script> <script src="/assets/common.js?v=20260907s"></script>
<script src="/assets/wall.js?v=20260906m"></script> <script src="/assets/wall.js?v=20260907s"></script>
</body> </body>
</html> </html>
+27 -4
View File
@@ -56,6 +56,12 @@ async function uplineSlides(email, depth = 3) {
} }
return out; return out;
} }
// 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() {
try { const j = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'admin-wall-ads.json'), 'utf8')); if (Array.isArray(j) && j.length) return j; } catch (e) {}
return [{ name: 'InstantAdPay', targetUrl: 'https://instantadpay.com/', bannerUrl: null }];
}
const chatHits = new Map(); const chatHits = new Map();
function chatLimited(ip) { function chatLimited(ip) {
const now = Date.now(), rec = chatHits.get(ip); const now = Date.now(), rec = chatHits.get(ip);
@@ -870,7 +876,7 @@ const server = http.createServer(async (req, res) => {
if (!QR || !data) { res.writeHead(404, baseHeaders()); return res.end(); } if (!QR || !data) { res.writeHead(404, baseHeaders()); return res.end(); }
try { try {
const svg = await QR.toString(data, { type: 'svg', margin: 1, width: 240, const svg = await QR.toString(data, { type: 'svg', margin: 1, width: 240,
color: { dark: '#0b1512', light: '#eef7f3' } }); color: { dark: '#0e7d5f', light: '#f2fbf8' } }); // mint-green modules, still high-contrast for reliable scanning
res.writeHead(200, baseHeaders({ 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' })); res.writeHead(200, baseHeaders({ 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' }));
return res.end(svg); return res.end(svg);
} catch (e) { res.writeHead(500, baseHeaders()); return res.end(); } } catch (e) { res.writeHead(500, baseHeaders()); return res.end(); }
@@ -944,14 +950,31 @@ const server = http.createServer(async (req, res) => {
let a = await accounts.byUsername(tok); let a = await accounts.byUsername(tok);
if (!a) a = await accounts.byCode(tok); if (!a) a = await accounts.byCode(tok);
if (!a) return json(res, 404, { error: 'No wall under that name.' }); if (!a) return json(res, 404, { error: 'No wall under that name.' });
const ladder = [a, ...await uplineSlides(a.email, 2)].slice(0, 3) const ladder = [a, ...await uplineSlides(a.email, 2)]
.map(x => ({ name: x.username ? '@' + x.username : x.memberId ? 'member #' + x.memberId : 'a member', .map(x => ({ name: x.username ? '@' + x.username : x.memberId ? 'member #' + x.memberId : 'a member',
bannerUrl: x.lineBannerUrl || null, targetUrl: x.lineTargetUrl || null })); 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 });
}
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', label: t[1] };
} catch (e) {}
}
const joinPath = '/join/' + (a.username || a.code); const joinPath = '/join/' + (a.username || a.code);
let socials = null; try { socials = a.socials ? JSON.parse(a.socials) : null; } catch (e) {} 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), return json(res, 200, { name: a.username ? '@' + a.username : 'member #' + (a.memberId || 0),
avatarUrl: a.avatarUrl || null, bio: a.bio || null, socials, avatarUrl: a.avatarUrl || null, bio: a.bio || null, socials, badge,
joinUrl: joinPath, qrUrl: '/api/qr?d=' + encodeURIComponent('https://instantadpay.com' + joinPath), ladder }); joinUrl: joinPath, qrUrl: '/api/qr?d=' + encodeURIComponent('https://instantadpay.com' + joinPath), ladder: finalLadder });
} }
// -- watch-to-earn video ads: serve one, then reward a server-clock-verified watch // -- watch-to-earn video ads: serve one, then reward a server-clock-verified watch
if (p === '/api/my/videos' && req.method === 'GET') { if (p === '/api/my/videos' && req.method === 'GET') {