Fix QR scanning, full npub display, explicit shop/sponsor labels on card

QR fix (critical):
- loadImage() was routing ALL urls through proxy.shakespeare.diy including
  data: URIs from QRCode.toDataURL() — proxy rejected them, QR never loaded
- Fix: only proxy http/https URLs; data: and blob: URIs load directly
- QR now renders reliably; scanning opens skate-spots.org/<npub> profile

npub display:
- Split full npub across two lines (32 chars each) instead of truncating
- Both lines shown in monospace below the brand header
- npub is now fully readable on the card

Explicit labeled sections:
- "SKATE SHOP" label in muted above shop name in accent color
- "SPONSORS" label in muted above sponsors text
- Sections only appear if data is entered — no empty space when blank
- Stats (Spots/Zaps/Events) shown as 3-column grid with label+value

Layout improvements:
- QR moved to top-right, rendered before other content (ensures it draws)
- Avatar sized 180px with "SINCE YYYY" underneath
- Identity column flows: name → nip05 → style chip → shop → sponsors → stats → skills
- Bottom divider line + year issued in footer
- fillTextFit() helper for clean text truncation with ellipsis

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-16 15:51:56 +07:00
parent a7158d4901
commit 79dc1e1dcf

View file

@ -100,12 +100,43 @@ async function loadImage(url: string): Promise<HTMLImageElement | null> {
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
// Proxy for CORS
img.src = url.startsWith('http') ? `https://proxy.shakespeare.diy/?url=${encodeURIComponent(url)}` : url;
setTimeout(() => resolve(null), 4000);
// Only proxy actual HTTP URLs — data: URIs must never go through proxy
if (url.startsWith('data:') || url.startsWith('blob:')) {
img.src = url;
} else {
img.src = `https://proxy.shakespeare.diy/?url=${encodeURIComponent(url)}`;
}
setTimeout(() => resolve(null), 5000);
});
}
// Draw text that wraps or truncates to fit maxWidth, returns lines drawn
function fillTextFit(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number): void {
let t = text;
while (ctx.measureText(t).width > maxWidth && t.length > 2) {
t = t.slice(0, -1);
}
if (t.length < text.length) t = t.slice(0, -1) + '…';
ctx.fillText(t, x, y);
}
// Draw a labeled row: small muted label above, value below
function drawLabeledRow(
ctx: CanvasRenderingContext2D,
label: string, value: string,
x: number, y: number, maxW: number,
labelColor: string, valueColor: string,
labelSize: number, valueSize: number,
bold = false,
) {
ctx.fillStyle = labelColor;
ctx.font = `${labelSize}px system-ui, sans-serif`;
ctx.fillText(label.toUpperCase(), x, y);
ctx.fillStyle = valueColor;
ctx.font = `${bold ? 'bold ' : ''}${valueSize}px system-ui, sans-serif`;
fillTextFit(ctx, value, x, y + valueSize + 2, maxW);
}
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y);
@ -127,7 +158,7 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
const ctx = canvas.getContext('2d')!;
const p = THEME_PALETTE[data.themeId] ?? THEME_PALETTE.thug;
// ── Background gradient ──
// ── Background gradient ──────────────────────────────────────────────────
const grad = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
grad.addColorStop(0, p.bg1);
grad.addColorStop(0.6, p.bg2);
@ -136,272 +167,282 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
ctx.fillStyle = grad;
ctx.fill();
// ── Top stripe ──
// ── Top color stripe (8px) ───────────────────────────────────────────────
const stripe = ctx.createLinearGradient(0, 0, CARD_W, 0);
stripe.addColorStop(0, p.stripe1);
stripe.addColorStop(1, p.stripe2);
ctx.fillStyle = stripe;
ctx.fillRect(0, 0, CARD_W, 20);
ctx.fillRect(0, 0, CARD_W, 8);
const PAD = 44;
const PAD = 40;
// ── Generate QR first (data: URI — never proxy) ──────────────────────────
const qrUrl = `https://skate-spots.org/${data.npub}`;
const qrSize = 180;
const qrX = CARD_W - PAD - qrSize;
const qrY = 28;
let qrImg: HTMLImageElement | null = null;
try {
const qrDataUrl = await QRCode.toDataURL(qrUrl, {
width: qrSize * 2, // render at 2× for crispness, then downscale
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#000000', light: '#ffffff' },
});
qrImg = await loadImage(qrDataUrl); // data: URI — won't proxy
} catch { /* QR skipped */ }
if (qrImg) {
// White padding behind QR
roundRect(ctx, qrX - 8, qrY - 8, qrSize + 16, qrSize + 16, 10);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
}
ctx.fillStyle = p.muted;
ctx.font = '15px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('SCAN TO VERIFY', qrX + qrSize / 2, qrY + qrSize + 22);
ctx.textAlign = 'left';
if (data.type === 'rider') {
// ── Brand + label ──
// ── Header: brand left, npub right ──────────────────────────────────────
ctx.fillStyle = p.accent;
ctx.font = 'bold 28px system-ui, sans-serif';
ctx.fillText('🛹 SKATESPOTS', PAD, 72);
ctx.font = 'bold 30px system-ui, sans-serif';
ctx.fillText('🛹 SKATESPOTS', PAD, 54);
ctx.fillStyle = p.muted;
ctx.font = '18px system-ui, sans-serif';
ctx.fillText('SKATER LICENSE', PAD, 95);
ctx.font = '17px system-ui, sans-serif';
ctx.fillText('SKATER LICENSE', PAD, 76);
// ── npub top-right ──
const shortN = `${data.npub.slice(0, 14)}…${data.npub.slice(-6)}`;
// Full npub split into two lines to fit (left of QR)
const npubMaxW = qrX - PAD - 20;
const npub1 = data.npub.slice(0, 32);
const npub2 = data.npub.slice(32);
ctx.fillStyle = p.muted;
ctx.font = '16px system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.fillText('NPUB', CARD_W - PAD, 60);
ctx.fillStyle = p.accent;
ctx.font = 'bold 18px monospace';
ctx.fillText(shortN, CARD_W - PAD, 82);
ctx.font = '14px monospace';
ctx.fillText(npub1, PAD, 100);
ctx.fillStyle = p.muted;
ctx.font = '16px system-ui, sans-serif';
ctx.fillText(`Since ${data.memberYear}`, CARD_W - PAD, 102);
ctx.textAlign = 'left';
ctx.fillText(npub2, PAD, 118);
// ── Avatar ──
const avatarSize = 200;
// ── Avatar (square, left column) ────────────────────────────────────────
const avatarSize = 180;
const avatarX = PAD;
const avatarY = 120;
roundRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 16);
const avatarY = 135;
roundRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 14);
ctx.fillStyle = p.chipBg;
ctx.fill();
if (data.avatarUrl) {
const img = await loadImage(data.avatarUrl);
if (img) {
ctx.save();
roundRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 16);
roundRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 14);
ctx.clip();
ctx.drawImage(img, avatarX, avatarY, avatarSize, avatarSize);
ctx.restore();
} else {
ctx.fillStyle = p.accent;
ctx.font = '80px serif';
ctx.textAlign = 'center';
ctx.fillText('🛹', avatarX + avatarSize / 2, avatarY + avatarSize / 2 + 28);
ctx.textAlign = 'left';
}
} else {
ctx.fillStyle = p.accent;
ctx.font = '80px serif';
ctx.textAlign = 'center';
ctx.fillText('🛹', avatarX + avatarSize / 2, avatarY + avatarSize / 2 + 28);
ctx.textAlign = 'left';
}
// ── Stats under avatar ──
const statsY = avatarY + avatarSize + 16;
const statItems = [
data.showSpots ? { label: 'Spots', value: String(data.spotsAdded), color: p.accent } : null,
data.showZaps ? { label: '⚡', value: String(data.zapsReceived), color: p.accent2 } : null,
data.showEvents ? { label: 'Events', value: String(data.eventsCreated), color: '#ff6600' } : null,
].filter(Boolean) as { label: string; value: string; color: string }[];
// ── Identity column (right of avatar, left of QR) ───────────────────────
const idX = PAD + avatarSize + 28;
const idMaxW = qrX - idX - 16;
let cy = 155; // current Y cursor
statItems.forEach((s, i) => {
const sy = statsY + i * 30;
ctx.fillStyle = p.muted;
ctx.font = '16px system-ui, sans-serif';
ctx.fillText(s.label, PAD, sy);
ctx.fillStyle = s.color;
ctx.font = 'bold 22px system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(s.value, PAD + avatarSize, sy);
ctx.textAlign = 'left';
});
// ── Identity: name ──
const idX = PAD + avatarSize + 36;
const idMaxW = CARD_W - idX - PAD - 160; // leave room for QR
// Name
ctx.fillStyle = p.text;
ctx.font = 'bold 52px system-ui, sans-serif';
// Truncate name to fit
let nameText = data.name.toUpperCase();
while (ctx.measureText(nameText).width > idMaxW && nameText.length > 3) {
nameText = nameText.slice(0, -1);
}
if (nameText !== data.name.toUpperCase()) nameText += '…';
ctx.fillText(nameText, idX, 175);
ctx.font = 'bold 46px system-ui, sans-serif';
fillTextFit(ctx, data.name.toUpperCase(), idX, cy, idMaxW);
cy += 52;
// ── nip05 ──
// nip05
if (data.nip05) {
ctx.fillStyle = p.accent;
ctx.font = '20px system-ui, sans-serif';
ctx.fillText(`@${data.nip05.split('@')[0]}`, idX, 205);
ctx.font = '19px system-ui, sans-serif';
fillTextFit(ctx, `@${data.nip05.split('@')[0]}`, idX, cy, idMaxW);
cy += 26;
}
// ── Style chip ──
// Style chip
if (data.style) {
ctx.font = 'bold 18px system-ui, sans-serif';
const chipW = ctx.measureText(data.style.toUpperCase()).width + 24;
roundRect(ctx, idX, 218, chipW, 30, 6);
ctx.font = 'bold 17px system-ui, sans-serif';
const chipTxt = data.style.toUpperCase();
const chipW = ctx.measureText(chipTxt).width + 22;
roundRect(ctx, idX, cy, chipW, 28, 6);
ctx.fillStyle = p.chipBg;
ctx.fill();
ctx.fillStyle = p.accent;
ctx.fillText(data.style.toUpperCase(), idX + 12, 238);
ctx.fillText(chipTxt, idX + 11, cy + 19);
cy += 38;
}
// ── Rides for ──
// ── SHOP section (explicit labeled) ─────────────────────────────────────
if (data.ridesFor) {
ctx.fillStyle = p.muted;
ctx.font = '18px system-ui, sans-serif';
ctx.fillText(`🏪 ${data.ridesFor}`, idX, 270);
ctx.font = '13px system-ui, sans-serif';
ctx.fillText('SKATE SHOP', idX, cy);
cy += 4;
ctx.fillStyle = p.accent;
ctx.font = 'bold 19px system-ui, sans-serif';
fillTextFit(ctx, data.ridesFor, idX, cy + 18, idMaxW);
cy += 32;
}
// ── Sponsors ──
// ── SPONSORS section (explicit labeled) ─────────────────────────────────
if (data.sponsors) {
ctx.fillStyle = p.muted;
ctx.font = '13px system-ui, sans-serif';
ctx.fillText('SPONSORS', idX, cy);
cy += 4;
ctx.fillStyle = p.text;
ctx.font = '18px system-ui, sans-serif';
ctx.fillText(`🤝 ${data.sponsors}`, idX, data.ridesFor ? 296 : 270);
fillTextFit(ctx, data.sponsors, idX, cy + 18, idMaxW);
cy += 32;
}
// ── Skill chips ──
// ── Stats row ────────────────────────────────────────────────────────────
const statItems = [
data.showSpots ? { label: 'SPOTS', value: String(data.spotsAdded), color: p.accent } : null,
data.showZaps ? { label: 'ZAPS ⚡', value: String(data.zapsReceived), color: p.accent2 } : null,
data.showEvents ? { label: 'EVENTS', value: String(data.eventsCreated), color: '#ff6600' } : null,
].filter(Boolean) as { label: string; value: string; color: string }[];
if (statItems.length > 0) {
cy = Math.max(cy, 330);
const statW = idMaxW / statItems.length;
statItems.forEach((s, i) => {
const sx = idX + i * statW;
ctx.fillStyle = p.muted;
ctx.font = '13px system-ui, sans-serif';
ctx.fillText(s.label, sx, cy);
ctx.fillStyle = s.color;
ctx.font = 'bold 28px system-ui, sans-serif';
ctx.fillText(s.value, sx, cy + 30);
});
cy += 50;
}
// ── Skill badges ─────────────────────────────────────────────────────────
const skillsToShow = [
...data.skills.slice(0, 4).map(id => {
...data.skills.slice(0, 5).map(id => {
const s = SKILL_OPTIONS.find(o => o.id === id);
return s ? { label: `${s.icon} ${s.label}`, gold: false } : null;
return s ? { label: `${s.label}`, gold: false } : null;
}).filter(Boolean),
...data.receivedBadges.slice(0, 2).map(b => {
...data.receivedBadges.slice(0, 3).map(b => {
const s = SKILL_OPTIONS.find(o => o.id === b.skill);
return s ? { label: `⭐ ${s.label}`, gold: true } : null;
return s ? { label: `★ ${s.label}`, gold: true } : null;
}).filter(Boolean),
] as { label: string; gold: boolean }[];
let chipX = idX;
let chipY = 330;
ctx.font = 'bold 16px system-ui, sans-serif';
skillsToShow.forEach(chip => {
const chipW = ctx.measureText(chip.label).width + 20;
if (chipX + chipW > CARD_W - PAD - 160) { chipX = idX; chipY += 34; }
roundRect(ctx, chipX, chipY, chipW, 26, 5);
ctx.fillStyle = chip.gold ? 'rgba(255,215,0,0.2)' : p.chipBg;
ctx.fill();
ctx.fillStyle = chip.gold ? '#ffd700' : p.accent;
ctx.fillText(chip.label, chipX + 10, chipY + 18);
chipX += chipW + 8;
});
// ── QR code ──
const qrSize = 160;
const qrX = CARD_W - PAD - qrSize;
const qrY = 120;
const qrUrl = `https://skate-spots.org/${data.npub}`;
try {
const qrDataUrl = await QRCode.toDataURL(qrUrl, { width: qrSize, margin: 1, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' } });
const qrImg = await loadImage(qrDataUrl);
if (qrImg) {
// White background patch
roundRect(ctx, qrX - 6, qrY - 6, qrSize + 12, qrSize + 12, 8);
ctx.fillStyle = '#ffffff';
if (skillsToShow.length > 0) {
cy = Math.max(cy, 390);
ctx.font = 'bold 15px system-ui, sans-serif';
let chipX = idX;
skillsToShow.forEach(chip => {
const chipW = ctx.measureText(chip.label).width + 18;
if (chipX + chipW > qrX - 8) { chipX = idX; cy += 30; }
roundRect(ctx, chipX, cy, chipW, 24, 5);
ctx.fillStyle = chip.gold ? 'rgba(255,215,0,0.2)' : p.chipBg;
ctx.fill();
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
}
} catch { /* skip QR if fails */ }
ctx.fillStyle = chip.gold ? '#ffd700' : p.accent;
ctx.fillText(chip.label, chipX + 9, cy + 17);
chipX += chipW + 7;
});
}
// ── Since year (under avatar) ────────────────────────────────────────────
const sinceY = avatarY + avatarSize + 22;
ctx.fillStyle = p.muted;
ctx.font = '14px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('SCAN TO VERIFY', qrX + qrSize / 2, qrY + qrSize + 22);
ctx.fillText(`SINCE ${data.memberYear}`, PAD + avatarSize / 2, sinceY);
ctx.textAlign = 'left';
} else {
// ── PARK ACCESS card ──
// ── PARK ACCESS card ─────────────────────────────────────────────────────
ctx.fillStyle = p.accent;
ctx.font = 'bold 28px system-ui, sans-serif';
ctx.fillText(`🏟️ ${(data.issuerName || 'SKATEPARK').toUpperCase()}`, PAD, 72);
ctx.font = 'bold 30px system-ui, sans-serif';
ctx.fillText(`🏟️ ${(data.issuerName || 'SKATEPARK').toUpperCase()}`, PAD, 54);
ctx.fillStyle = p.muted;
ctx.font = '18px system-ui, sans-serif';
ctx.fillText('ACCESS PASS', PAD, 95);
ctx.font = '17px system-ui, sans-serif';
ctx.fillText('ACCESS PASS', PAD, 76);
// Access level chip (top right)
ctx.font = 'bold 20px system-ui, sans-serif';
const alW = ctx.measureText((data.accessLevel || 'FULL ACCESS').toUpperCase()).width + 28;
roundRect(ctx, CARD_W - PAD - alW, 52, alW, 36, 8);
// Access level chip
ctx.font = 'bold 19px system-ui, sans-serif';
const alTxt = (data.accessLevel || 'FULL ACCESS').toUpperCase();
const alW = ctx.measureText(alTxt).width + 28;
roundRect(ctx, PAD, 90, alW, 32, 7);
ctx.fillStyle = p.chipBg;
ctx.fill();
ctx.fillStyle = p.accent;
ctx.fillText((data.accessLevel || 'FULL ACCESS').toUpperCase(), CARD_W - PAD - alW + 14, 76);
ctx.fillText(alTxt, PAD + 14, 111);
// Avatar
const avatarSize = 200;
const avatarSize = 180;
const avatarX = PAD;
const avatarY = 120;
roundRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 16);
const avatarY = 140;
roundRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 14);
ctx.fillStyle = p.chipBg;
ctx.fill();
if (data.avatarUrl) {
const img = await loadImage(data.avatarUrl);
if (img) {
ctx.save();
roundRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 16);
roundRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 14);
ctx.clip();
ctx.drawImage(img, avatarX, avatarY, avatarSize, avatarSize);
ctx.restore();
}
}
// Recipient name
const idX = PAD + avatarSize + 36;
const idX = PAD + avatarSize + 28;
const idMaxW = qrX - idX - 16;
let cy = 170;
ctx.fillStyle = p.text;
ctx.font = 'bold 46px system-ui, sans-serif';
ctx.fillText((data.recipientName || 'MEMBER NAME').toUpperCase(), idX, 175);
ctx.font = 'bold 44px system-ui, sans-serif';
fillTextFit(ctx, (data.recipientName || 'MEMBER NAME').toUpperCase(), idX, cy, idMaxW);
cy += 50;
// Full npub split into two lines
ctx.fillStyle = p.muted;
ctx.font = '14px monospace';
ctx.fillText(data.npub.slice(0, 32), idX, cy);
cy += 20;
ctx.fillText(data.npub.slice(32), idX, cy);
cy += 28;
ctx.fillStyle = p.muted;
ctx.font = '18px monospace';
ctx.fillText(`${data.npub.slice(0, 20)}…`, idX, 208);
ctx.font = '18px system-ui, sans-serif';
ctx.fillText('Issued by park owner · Nostr-signed', idX, 236);
ctx.font = '16px system-ui, sans-serif';
ctx.fillText('Issued by park owner · Nostr-signed', idX, cy);
cy += 28;
if (data.validThru) {
ctx.fillStyle = p.muted;
ctx.font = '18px system-ui, sans-serif';
ctx.fillText('Valid thru', idX, 270);
ctx.font = '14px system-ui, sans-serif';
ctx.fillText('VALID THRU', idX, cy);
cy += 4;
ctx.fillStyle = p.accent;
ctx.font = 'bold 28px system-ui, sans-serif';
ctx.fillText(data.validThru, idX, 298);
ctx.fillText(data.validThru, idX, cy + 26);
}
// QR
const qrSize = 160;
const qrX = CARD_W - PAD - qrSize;
const qrY = 120;
const qrUrl = `https://skate-spots.org/${data.npub}`;
try {
const qrDataUrl = await QRCode.toDataURL(qrUrl, { width: qrSize, margin: 1, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' } });
const qrImg = await loadImage(qrDataUrl);
if (qrImg) {
roundRect(ctx, qrX - 6, qrY - 6, qrSize + 12, qrSize + 12, 8);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
}
} catch { /* skip */ }
ctx.fillStyle = p.muted;
ctx.font = '14px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('SCAN TO VERIFY', qrX + qrSize / 2, qrY + qrSize + 22);
ctx.textAlign = 'left';
}
// ── Bottom bar (both types) ──
const shortN = `${data.npub.slice(0, 14)}…${data.npub.slice(-6)}`;
// ── Bottom divider line ──────────────────────────────────────────────────
ctx.strokeStyle = p.muted;
ctx.globalAlpha = 0.2;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD, CARD_H - 42);
ctx.lineTo(CARD_W - PAD, CARD_H - 42);
ctx.stroke();
ctx.globalAlpha = 1;
// ── Bottom bar ──────────────────────────────────────────────────────────
ctx.fillStyle = p.muted;
ctx.font = '15px system-ui, sans-serif';
ctx.font = '14px system-ui, sans-serif';
ctx.fillText('SKATE-SPOTS.ORG · POWERED BY NOSTR', PAD, CARD_H - 18);
ctx.textAlign = 'right';
ctx.fillText(shortN, CARD_W - PAD, CARD_H - 18);
// Show year issued
ctx.fillText(data.memberYear, CARD_W - PAD, CARD_H - 18);
ctx.textAlign = 'left';
return canvas.toDataURL('image/png');