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:
+1
-1
@@ -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.
|
||||
- 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.
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
+50
-34
@@ -142,43 +142,59 @@
|
||||
}, 'image/jpeg', 0.86);
|
||||
});
|
||||
}
|
||||
// compose a shareable badge image on a canvas (zero-dep) and download it
|
||||
// composite the ornate badge template with the member's @username on the ribbon
|
||||
// Share: a picker (Marty, 2026-09-13). The composed badge is stored once so a public page at
|
||||
// /b/<username>/<badge> carries it as the preview image; every network gets that link.
|
||||
function shareBadge(key, d) {
|
||||
const b = BADGES.find(x => x.key === key); if (!b) return;
|
||||
const who = d.username ? d.username : d.memberId ? 'member #' + d.memberId : '';
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const c = document.createElement('canvas'); c.width = img.width; c.height = img.height;
|
||||
const x = c.getContext('2d');
|
||||
x.drawImage(img, 0, 0);
|
||||
if (who) { // name it on the blank ribbon: gold with a dark outline so it reads on any badge color
|
||||
x.textAlign = 'center';
|
||||
x.textBaseline = 'middle';
|
||||
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';
|
||||
}
|
||||
if (!d.username) { IAP.status('Pick a username first; your share page carries it.', 'bad'); return; }
|
||||
IAP.status('Preparing your ' + b.label + ' badge…', 'ok');
|
||||
composeBadge(key, d, c => {
|
||||
if (!c) { IAP.status('Could not load the badge art.', 'bad'); return; }
|
||||
c.toBlob(async bl => {
|
||||
let page = null;
|
||||
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; }
|
||||
catch (e) { IAP.status(e.message || 'Could not prepare the share page.', 'bad'); return; }
|
||||
openShareSheet(b, d, bl, page);
|
||||
}, 'image/jpeg', 0.9);
|
||||
});
|
||||
}
|
||||
function openShareSheet(b, d, blob, page) {
|
||||
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 {
|
||||
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);
|
||||
} catch (e) { IAP.status('Could not generate the image.', 'bad'); }
|
||||
};
|
||||
img.onerror = () => IAP.status('Could not load the badge art.', 'bad');
|
||||
img.src = b.img; // same-origin, canvas stays untainted
|
||||
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,
|
||||
|
||||
+1
-1
@@ -915,7 +915,7 @@
|
||||
<script src="/assets/common.js?v=20260913a"></script>
|
||||
<script src="/assets/wallet.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1310,6 +1310,20 @@ const server = http.createServer(async (req, res) => {
|
||||
console.log('badge posted', s.email, key, 'sent', 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
|
||||
const s = await auth.fromRequest(req);
|
||||
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 === '/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)
|
||||
// -- 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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)
|
||||
if (p === '/blog' || p === '/blog/' || /^\/blog\/page\/\d+$/.test(p) || /^\/blog\/tag\/[^/]+$/.test(p)) {
|
||||
const posts = await blog.listPublished();
|
||||
|
||||
Reference in New Issue
Block a user