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:
parent
a7158d4901
commit
79dc1e1dcf
1 changed files with 213 additions and 172 deletions
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue