diff --git a/chatbot.js b/chatbot.js index 016da7c..1486464 100644 --- a/chatbot.js +++ b/chatbot.js @@ -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//, 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. diff --git a/public/assets/my.js b/public/assets/my.js index be3e94f..3cd14f5 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -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// 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 = ''; + 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, diff --git a/public/my.html b/public/my.html index 84f25cc..a982d68 100644 --- a/public/my.html +++ b/public/my.html @@ -915,7 +915,7 @@ - + diff --git a/server.js b/server.js index a6eb72b..79aa1da 100644 --- a/server.js +++ b/server.js @@ -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// (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 = '' + esc(title) + '' + + '' + + '' + + '' + + '' + + '' + + '
' + esc(label) + ' badge for @' + esc(un) + '

@' + esc(un) + ' unlocked ' + label + '

' + 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.

' + + 'Join @' + esc(un) + '\u2019s line free

Advertising with a performance referral program. Not an investment; no income is guaranteed.

' + + ''; + 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();