Profile build-out (avatar + bio) + shareable bio page with QR

- Profile pane: avatar upload + bio, with a link to your public page
- Wall becomes a bio page: avatar, bio, scannable join QR, line ladder, join CTA
- qrcode npm dep; /api/qr renders SVG QR server-side (CSP-clean img)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-06 08:49:20 -05:00
parent 9e05711f4e
commit 1aab52ca90
15 changed files with 601 additions and 33 deletions
+27 -1
View File
@@ -17,6 +17,7 @@ const accounts = require('./accounts');
const ads = require('./ads');
const mailer = require('./mailer');
const messages = require('./messages');
let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ }
const chatbot = require('./chatbot');
const PORT = Number(process.env.PORT || 3000);
@@ -651,6 +652,29 @@ const server = http.createServer(async (req, res) => {
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await messages.markRead(s.email, m[1]));
}
// -- QR code (SVG) for any InstantAdPay URL — used on bio/wall pages
if (p === '/api/qr' && req.method === 'GET') {
const data = String(u.searchParams.get('d') || '').slice(0, 300);
if (!QR || !data) { res.writeHead(404, baseHeaders()); return res.end(); }
try {
const svg = await QR.toString(data, { type: 'svg', margin: 1, width: 240,
color: { dark: '#0b1512', light: '#eef7f3' } });
res.writeHead(200, baseHeaders({ 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' }));
return res.end(svg);
} catch (e) { res.writeHead(500, baseHeaders()); return res.end(); }
}
// -- profile: avatar + bio
if (p === '/api/my/profile-details' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const b = await readBody(req);
const avatar = b.avatarUrl === undefined ? undefined : String(b.avatarUrl || '').trim();
if (avatar && !/^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|https:\/\/[^\s]+)$/i.test(avatar))
return json(res, 400, { error: 'Avatar must be an uploaded image or an https image URL.' });
const bio = b.bio === undefined ? undefined : String(b.bio || '').trim().slice(0, 600);
const r = await accounts.setProfile(s.email, avatar, bio);
return json(res, r.error ? 400 : 200, r);
}
// -- line banner: the member's viral slot on welcome tours + their wall
if (p === '/api/my/linebanner' && req.method === 'POST') {
const s = await auth.fromRequest(req);
@@ -700,8 +724,10 @@ const server = http.createServer(async (req, res) => {
const ladder = [a, ...await uplineSlides(a.email, 2)].slice(0, 3)
.map(x => ({ name: x.username ? '@' + x.username : x.memberId ? 'member #' + x.memberId : 'a member',
bannerUrl: x.lineBannerUrl || null, targetUrl: x.lineTargetUrl || null }));
const joinPath = '/join/' + (a.username || a.code);
return json(res, 200, { name: a.username ? '@' + a.username : 'member #' + (a.memberId || 0),
joinUrl: '/join/' + (a.username || a.code), ladder });
avatarUrl: a.avatarUrl || null, bio: a.bio || null,
joinUrl: joinPath, qrUrl: '/api/qr?d=' + encodeURIComponent('https://instantadpay.com' + joinPath), ladder });
}
// -- watch-to-earn video ads: serve one, then reward a server-clock-verified watch
if (p === '/api/my/videos' && req.method === 'GET') {