Achievement badges: personalised badge image auto-posted to the Telegram payments topic and main group on unlock (once per badge), Post to Telegram button, feed toast

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-13 16:55:43 -05:00
parent 0c58b80184
commit 964a02a342
5 changed files with 101 additions and 3 deletions
+44 -2
View File
@@ -87,17 +87,58 @@
return '<div class="badge-a' + (got ? '' : ' locked') + '">'
+ '<img class="badge-img" src="' + b.img + '" alt="' + b.label + ' badge" loading="lazy">'
+ '<div class="bl">' + b.label + '</div><div class="bs">' + b.sub + '</div>'
+ (got ? '<button class="share" data-badge="' + b.key + '">Share</button>' : '<div class="bs">🔒 locked</div>') + '</div>';
+ (got ? '<button class="share" data-badge="' + b.key + '">Share</button> <button class="share" data-badgepost="' + b.key + '">Post to Telegram</button>' : '<div class="bs">🔒 locked</div>') + '</div>';
}).join('');
strip.querySelectorAll('[data-badge]').forEach(btn =>
btn.addEventListener('click', () => shareBadge(btn.dataset.badge, d)));
// celebrate anything newly granted this load
strip.querySelectorAll('[data-badgepost]').forEach(btn =>
btn.addEventListener('click', () => postBadge(btn.dataset.badgepost, d, btn)));
fetch('/api/my/badge-posted').then(r => r.json()).then(r => (r.posted || []).forEach(k => { const b = strip.querySelector('[data-badgepost="' + k + '"]'); if (b) { b.textContent = 'Posted ✓'; b.disabled = true; } })).catch(() => {});
// celebrate anything newly granted this load, and post the branded badge to the team channels (Marty, 2026-09-13)
if (d.milestonesGranted && d.milestonesGranted.length) {
const total = d.milestonesGranted.reduce((s, g) => s + g.credited, 0);
const names = d.milestonesGranted.map(g => (BADGES.find(b => b.key === g.key) || {}).label).filter(Boolean).join(', ');
IAP.status('🏆 Achievement unlocked: ' + names + ' — +' + total + ' bonus credits!', 'ok');
d.milestonesGranted.forEach((g, i) => setTimeout(() => postBadge(g.key, d, strip.querySelector('[data-badgepost="' + g.key + '"]')), 800 + i * 1500));
}
}
// draw the badge with the member's name on the ribbon; cb(canvas) or cb(null)
function composeBadge(key, d, cb) {
const b = BADGES.find(x => x.key === key); if (!b) return cb(null);
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) {
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);
}
cb(c);
};
img.onerror = () => cb(null);
img.src = b.img;
}
async function postBadge(key, d, btn) {
if (btn && btn.disabled) return;
if (btn) { btn.disabled = true; btn.textContent = 'Posting…'; }
composeBadge(key, d, c => {
if (!c) { if (btn) { btn.disabled = false; btn.textContent = 'Post to Telegram'; } return; }
c.toBlob(async bl => {
try {
const r = await (await fetch('/api/my/badge-post?key=' + encodeURIComponent(key), { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: bl })).json();
if (r.error) throw new Error(r.error);
if (btn) { btn.textContent = 'Posted ✓'; btn.disabled = true; }
if (!r.already) IAP.status('Your badge is posted in the team channels.', 'ok');
} catch (e) { if (btn) { btn.disabled = false; btn.textContent = 'Post to Telegram'; } IAP.status(e.message || 'Could not post the badge.', 'bad'); }
}, '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
function shareBadge(key, d) {
@@ -296,6 +337,7 @@
// site-wide activity (not about me): joins, tank, adoptions, purchases, payouts, qualifications
if (ev.type === 'Joined') { communityToast('👋 ' + ev.name + ' just joined' + (ev.tank ? ' and is waiting for a sponsor in the holding tank' : '')); liveRefresh(); return; }
if (ev.type === 'Adopted') { communityToast('🤝 ' + ev.sponsor + ' picked up ' + ev.member + ' from the holding tank'); liveRefresh(); return; }
if (ev.type === 'Badge') { communityToast('🏆 ' + ev.member + ' unlocked ' + ev.label); return; }
if (ev.type === 'Released') { communityToast('🪣 ' + ev.sponsor + ' returned ' + ev.member + ' to the holding tank'); liveRefresh(); return; }
const mine = MYID && (ev.recipientId === MYID || ev.toId === MYID || ev.sponsorId === MYID || ev.skippedId === MYID || ev.buyerId === MYID);
if (!mine) {