From 79dc1e1dcfccd6d0468ccb6c002be452c5341edd Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Tue, 16 Jun 2026 15:51:56 +0700 Subject: [PATCH] Fix QR scanning, full npub display, explicit shop/sponsor labels on card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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 --- src/pages/RiderCardPage.tsx | 385 ++++++++++++++++++++---------------- 1 file changed, 213 insertions(+), 172 deletions(-) diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index 13b3e91..8b0e6fc 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -100,12 +100,43 @@ async function loadImage(url: string): Promise { 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 { 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 { 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');