Badge share sheet: public badge page with preview image (/b/<user>/<badge>), X/Facebook/Telegram/WhatsApp/LinkedIn/native share, copy link, save image

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-13 17:13:18 -05:00
parent e63bc57a99
commit 8e415c3cf4
4 changed files with 92 additions and 36 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ FACTS:
- 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. - 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. - 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, instantadpay.com/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. - BLOG (public, instantadpay.com/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' downloads the image. - 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, the phone's own share menu, copy link, save image) that shares the member's public badge page instantadpay.com/b/<username>/<badge>, which shows the badge and their join link.
- 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). - 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 instantadpay.com/from/faucetwave or instantadpay.com/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. - LEGACY WELCOME (former Faucet Wave / Tier One Ads members): they join through instantadpay.com/from/faucetwave or instantadpay.com/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. - 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.
+50 -34
View File
@@ -142,43 +142,59 @@
}, 'image/jpeg', 0.86); }, 'image/jpeg', 0.86);
}); });
} }
// compose a shareable badge image on a canvas (zero-dep) and download it // Share: a picker (Marty, 2026-09-13). The composed badge is stored once so a public page at
// composite the ornate badge template with the member's @username on the ribbon // /b/<username>/<badge> carries it as the preview image; every network gets that link.
function shareBadge(key, d) { function shareBadge(key, d) {
const b = BADGES.find(x => x.key === key); if (!b) return; const b = BADGES.find(x => x.key === key); if (!b) return;
const who = d.username ? d.username : d.memberId ? 'member #' + d.memberId : ''; if (!d.username) { IAP.status('Pick a username first; your share page carries it.', 'bad'); return; }
const img = new Image(); IAP.status('Preparing your ' + b.label + ' badge…', 'ok');
img.onload = () => { composeBadge(key, d, c => {
const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; if (!c) { IAP.status('Could not load the badge art.', 'bad'); return; }
const x = c.getContext('2d'); c.toBlob(async bl => {
x.drawImage(img, 0, 0); let page = null;
if (who) { // name it on the blank ribbon: gold with a dark outline so it reads on any badge color try { const r = await (await fetch('/api/my/badge-image?key=' + encodeURIComponent(key), { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: bl })).json(); if (r.error) throw new Error(r.error); page = r.page; }
x.textAlign = 'center'; catch (e) { IAP.status(e.message || 'Could not prepare the share page.', 'bad'); return; }
x.textBaseline = 'middle'; openShareSheet(b, d, bl, page);
x.font = 'bold ' + Math.round(c.width * 0.055) + 'px Sora, "Segoe UI", sans-serif';
const y = c.height * (b.ribbonY || 0.75);
x.lineJoin = 'round';
x.lineWidth = Math.max(3, Math.round(c.width * 0.008));
x.strokeStyle = 'rgba(4,20,15,.9)';
x.strokeText(who, c.width / 2, y);
x.fillStyle = '#ffd15c';
x.fillText(who, c.width / 2, y);
x.textAlign = 'left';
x.textBaseline = 'alphabetic';
}
try {
c.toBlob(bl => {
const a = document.createElement('a');
a.href = URL.createObjectURL(bl);
a.download = 'instantadpay-' + b.label.toLowerCase() + '-badge.jpg';
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
IAP.status('Your ' + b.label + ' badge is saved — share it anywhere.', 'ok');
}, 'image/jpeg', 0.9); }, 'image/jpeg', 0.9);
} catch (e) { IAP.status('Could not generate the image.', 'bad'); } });
}; }
img.onerror = () => IAP.status('Could not load the badge art.', 'bad'); function openShareSheet(b, d, blob, page) {
img.src = b.img; // same-origin, canvas stays untainted const text = 'I just unlocked the ' + b.label + ' badge on InstantAdPay (' + b.sub + '). Advertising that pays you back, on-chain, the same second a package sells.';
const enc = encodeURIComponent;
const links = [
['X', 'https://x.com/intent/tweet?text=' + enc(text) + '&url=' + enc(page)],
['Facebook', 'https://www.facebook.com/sharer/sharer.php?u=' + enc(page)],
['Telegram', 'https://t.me/share/url?url=' + enc(page) + '&text=' + enc(text)],
['WhatsApp', 'https://wa.me/?text=' + enc(text + ' ' + page)],
['LinkedIn', 'https://www.linkedin.com/sharing/share-offsite/?url=' + enc(page)]
];
const back = document.createElement('div'); back.className = 'modal-back'; back.style.zIndex = '200';
back.innerHTML = '<div class="modal-card" role="dialog" aria-modal="true" style="max-width:420px">'
+ '<h3 style="margin:0 0 4px">Share your ' + b.label + ' badge</h3><p class="muted small" style="margin:0 0 12px">Each option shares your badge page, which shows the image and your join link.</p>'
+ '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">'
+ links.map(l => '<a class="btn sec small" target="_blank" rel="noopener" href="' + l[1] + '" style="text-align:center">' + l[0] + '</a>').join('')
+ (navigator.share ? '<button type="button" class="btn sec small" data-sh="native">More…</button>' : '')
+ '<button type="button" class="btn sec small" data-sh="copy">Copy link</button>'
+ '<button type="button" class="btn sec small" data-sh="save">Save image</button>'
+ '</div><p class="small muted" style="margin:12px 0 0;word-break:break-all">' + page + '</p>'
+ '<div style="text-align:right;margin-top:12px"><button type="button" class="btn sec small" data-sh="close">Close</button></div></div>';
document.body.appendChild(back);
const close = () => back.remove();
back.addEventListener('click', e => { if (e.target === back) close(); });
back.querySelector('[data-sh="close"]').addEventListener('click', close);
back.querySelector('[data-sh="copy"]').addEventListener('click', async () => { try { await navigator.clipboard.writeText(page); IAP.status('Link copied.', 'ok'); } catch (e) { IAP.status('Copy this: ' + page, 'ok'); } });
back.querySelector('[data-sh="save"]').addEventListener('click', () => {
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'instantadpay-' + b.label.toLowerCase() + '-badge.jpg'; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
IAP.status('Your ' + b.label + ' badge is saved.', 'ok');
});
const nat = back.querySelector('[data-sh="native"]');
if (nat) nat.addEventListener('click', async () => {
try {
const file = new File([blob], 'instantadpay-' + b.label.toLowerCase() + '-badge.jpg', { type: 'image/jpeg' });
if (navigator.canShare && navigator.canShare({ files: [file] })) await navigator.share({ files: [file], title: b.label + ' badge', text: text + ' ' + page });
else await navigator.share({ title: b.label + ' badge', text, url: page });
} catch (e) {}
});
} }
// milestone stepper under "Your next move": lit nodes for what's done, // milestone stepper under "Your next move": lit nodes for what's done,
+1 -1
View File
@@ -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=20260913h"></script> <script src="/assets/my.js?v=20260913i"></script>
<script src="/assets/chat.js?v=20260907l"></script> <script src="/assets/chat.js?v=20260907l"></script>
</body> </body>
</html> </html>
+40
View File
@@ -1310,6 +1310,20 @@ const server = http.createServer(async (req, res) => {
console.log('badge posted', s.email, key, 'sent', sent); console.log('badge posted', s.email, key, 'sent', sent);
return json(res, 200, { ok: true, sent }); return json(res, 200, { ok: true, sent });
} }
// -- member's own share: store the composed badge so a public page can carry it as the preview image
if (p === '/api/my/badge-image' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const key = String(u.searchParams.get('key') || '');
if (!BADGE_META[key]) return json(res, 400, { error: 'Unknown badge.' });
let jpeg; try { jpeg = await readRaw(req, 1.5 * 1024 * 1024); } catch (e) { return json(res, 413, { error: 'Image too large.' }); }
if (!jpeg || jpeg.length < 2000 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return json(res, 400, { error: 'That is not a JPEG.' });
if (!(await ads.milestonesOf(s.email)).includes(key)) return json(res, 400, { error: 'You have not unlocked that badge yet.' });
const a = await accounts.byEmail(s.email);
if (!a || !a.username) return json(res, 400, { error: 'Pick a username first; the share page carries it.' });
fs.writeFileSync(path.join(UPLOADS_DIR, 'badge-' + a.username + '-' + key + '.jpg'), jpeg);
return json(res, 200, { ok: true, page: 'https://instantadpay.com/b/' + a.username + '/' + key, image: 'https://instantadpay.com/badge-img/' + a.username + '/' + key + '.jpg' });
}
if (p === '/api/my/badge-posted' && req.method === 'GET') { // which of my badges are already on Telegram if (p === '/api/my/badge-posted' && req.method === 'GET') { // which of my badges are already on Telegram
const s = await auth.fromRequest(req); const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
@@ -2348,6 +2362,32 @@ const server = http.createServer(async (req, res) => {
if (p === '/plays') return sendFile(res, path.join(PUBLIC_DIR, 'plays.html')); if (p === '/plays') return sendFile(res, path.join(PUBLIC_DIR, 'plays.html'));
if (p === '/partners') return sendFile(res, path.join(PUBLIC_DIR, 'partners.html')); // site-owner kit (Marty, 2026-09-12) if (p === '/partners') return sendFile(res, path.join(PUBLIC_DIR, 'partners.html')); // site-owner kit (Marty, 2026-09-12)
if (p === '/earning') return sendFile(res, path.join(PUBLIC_DIR, 'earning.html')); // member guide: how earning works (2026-09-12) if (p === '/earning') return sendFile(res, path.join(PUBLIC_DIR, 'earning.html')); // member guide: how earning works (2026-09-12)
// -- badge share page + image: /b/<username>/<key> (OG preview = the member's composed badge) (Marty, 2026-09-13)
m = /^\/badge-img\/([a-z0-9_]{3,20})\/(payouts|firstBuyer|level2|level3)\.jpg$/.exec(p);
if (m) return sendFile(res, path.join(UPLOADS_DIR, 'badge-' + m[1] + '-' + m[2] + '.jpg'));
m = /^\/b\/([a-z0-9_]{3,20})\/(payouts|firstBuyer|level2|level3)$/i.exec(p);
if (m) {
const un = m[1].toLowerCase(), key = m[2];
const a = await accounts.byUsername(un);
const file = path.join(UPLOADS_DIR, 'badge-' + un + '-' + key + '.jpg');
if (!a || !fs.existsSync(file) || !(await ads.milestonesOf(a.email)).includes(key)) { res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' })); return res.end('Not found'); }
const [label, sub] = BADGE_META[key];
const esc = t => String(t || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const url = 'https://instantadpay.com/b/' + un + '/' + key, img = 'https://instantadpay.com/badge-img/' + un + '/' + key + '.jpg';
const title = '@' + un + ' unlocked ' + label + ' on InstantAdPay';
const desc = label + ': ' + sub + '. InstantAdPay pays sponsors in the same transaction, on-chain. Join @' + un + '\u2019s line free.';
const html = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' + esc(title) + '</title>'
+ '<meta name="description" content="' + esc(desc) + '"><link rel="canonical" href="' + url + '"><meta name="robots" content="noindex">'
+ '<meta property="og:type" content="website"><meta property="og:site_name" content="InstantAdPay"><meta property="og:title" content="' + esc(title) + '"><meta property="og:description" content="' + esc(desc) + '"><meta property="og:url" content="' + url + '"><meta property="og:image" content="' + img + '"><meta property="og:image:width" content="1024"><meta property="og:image:height" content="1024">'
+ '<meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="' + esc(title) + '"><meta name="twitter:description" content="' + esc(desc) + '"><meta name="twitter:image" content="' + img + '">'
+ '<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>.bp{max-width:560px;text-align:center;padding:40px 16px 60px}.bp img{width:100%;max-width:520px;border-radius:18px;border:1px solid var(--line);box-shadow:0 20px 60px rgba(0,0,0,.45)}.bp h1{font-size:clamp(24px,5vw,34px);margin:22px 0 8px}.bp p{color:var(--muted);max-width:44ch;margin:0 auto 20px}</style></head>'
+ '<body><div class="wrap bp"><img src="' + img + '" alt="' + esc(label) + ' badge for @' + esc(un) + '"><h1>@' + esc(un) + ' unlocked <em>' + label + '</em></h1><p>' + esc(sub.charAt(0).toUpperCase() + sub.slice(1)) + '. On InstantAdPay every ad package that sells pays the sponsor in the same transaction, straight to their wallet, on-chain.</p>'
+ '<a class="btn" href="/join/' + esc(un) + '">Join @' + esc(un) + '\u2019s line free</a><p class="small" style="margin-top:26px">Advertising with a performance referral program. Not an investment; no income is guaranteed.</p></div>'
+ '<script src="/assets/common.js?v=20260913a"></script><script src="/assets/blog-page.js?v=20260912a"></script></body></html>';
res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' }));
return res.end(html);
}
// -- blog: server-rendered so crawlers get real HTML + metadata (Marty, 2026-09-12) // -- blog: server-rendered so crawlers get real HTML + metadata (Marty, 2026-09-12)
if (p === '/blog' || p === '/blog/' || /^\/blog\/page\/\d+$/.test(p) || /^\/blog\/tag\/[^/]+$/.test(p)) { if (p === '/blog' || p === '/blog/' || /^\/blog\/page\/\d+$/.test(p) || /^\/blog\/tag\/[^/]+$/.test(p)) {
const posts = await blog.listPublished(); const posts = await blog.listPublished();