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
+50 -34
View File
@@ -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,