Replace html2canvas with Canvas 2D renderer for card export
html2canvas was capturing the full browser viewport instead of just the 340×214px card element — causing the giant oversized/wrong layout in the PNG. New approach: drawCardToCanvas() draws the card entirely via Canvas 2D API: - Creates an 856×540px canvas (10× credit-card mm dimensions, print-ready) - Draws gradient background, top stripe, avatar (with CORS proxy), text, skill chips, stats, and QR code using direct canvas drawing commands - QR code generated via qrcode lib toDataURL(), then drawn as image on canvas - Avatar loaded via proxy.shakespeare.diy for CORS, with 4s timeout fallback - Theme colors from THEME_PALETTE map — same 6 themes as the screen card - No DOM capture, no scaling issues, no CSS stripping — output is pixel-perfect QR code explanation: - Encodes https://skate-spots.org/<npub> - Scanning opens the rider's SkateSpots profile (NIP19Page handles /npub1...) - White background patch behind QR for reliable scanning on dark card themes Other changes: - "Rider License" → "Skater License" everywhere - Remove html2canvas dependency usage (kept package but unused for now) Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
68922280cb
commit
a7158d4901
1 changed files with 399 additions and 34 deletions
|
|
@ -16,7 +16,7 @@
|
|||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import html2canvas from 'html2canvas';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
|
|
@ -43,6 +43,370 @@ import { cn } from '@/lib/utils';
|
|||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
|
||||
// ─── Card canvas renderer ─────────────────────────────────────────────────────
|
||||
// Draws the card entirely via Canvas 2D API — 100% reliable export,
|
||||
// no CSS capture, no scaling issues, no background-stripping.
|
||||
// Card is 856×540px (10× credit-card mm, high-res for printing).
|
||||
|
||||
const CARD_W = 856;
|
||||
const CARD_H = 540;
|
||||
|
||||
interface CanvasCardData {
|
||||
type: 'rider' | 'park';
|
||||
themeId: string;
|
||||
name: string;
|
||||
nip05?: string;
|
||||
avatarUrl?: string;
|
||||
pubkey: string;
|
||||
npub: string;
|
||||
style: string;
|
||||
skills: string[];
|
||||
sponsors: string;
|
||||
ridesFor: string;
|
||||
showSpots: boolean;
|
||||
showZaps: boolean;
|
||||
showEvents: boolean;
|
||||
spotsAdded: number;
|
||||
zapsReceived: number;
|
||||
eventsCreated: number;
|
||||
memberYear: string;
|
||||
receivedBadges: BadgeAward[];
|
||||
// Park
|
||||
issuerName: string;
|
||||
recipientName: string;
|
||||
accessLevel: string;
|
||||
validThru: string;
|
||||
}
|
||||
|
||||
// Theme palette for canvas drawing
|
||||
const THEME_PALETTE: Record<string, {
|
||||
bg1: string; bg2: string; bg3: string;
|
||||
accent: string; accent2: string;
|
||||
stripe1: string; stripe2: string;
|
||||
muted: string; chipBg: string;
|
||||
text: string;
|
||||
}> = {
|
||||
thug: { bg1: '#0a0a0a', bg2: '#1a0008', bg3: '#0d0d0d', accent: '#ff0040', accent2: '#ccff00', stripe1: '#ff0040', stripe2: '#ff6600', muted: 'rgba(255,255,255,0.55)', chipBg: 'rgba(255,0,64,0.25)', text: '#ffffff' },
|
||||
lime: { bg1: '#050f00', bg2: '#0a1a00', bg3: '#050f00', accent: '#ccff00', accent2: '#ff6600', stripe1: '#ccff00', stripe2: '#88ff00', muted: 'rgba(255,255,255,0.55)', chipBg: 'rgba(204,255,0,0.2)', text: '#ffffff' },
|
||||
chrome: { bg1: '#1a1a1a', bg2: '#2e2e2e', bg3: '#111111', accent: '#c8c8c8', accent2: '#ff6600', stripe1: '#888888', stripe2: '#cccccc', muted: 'rgba(232,232,232,0.6)', chipBg: 'rgba(200,200,200,0.15)', text: '#e8e8e8' },
|
||||
wood: { bg1: '#3d2008', bg2: '#5c3210', bg3: '#2e1804', accent: '#f5c842', accent2: '#ff6600', stripe1: '#8b5e3c', stripe2: '#d4a05a', muted: 'rgba(245,230,200,0.65)', chipBg: 'rgba(160,100,40,0.3)', text: '#f5e6c8' },
|
||||
midnight: { bg1: '#020818', bg2: '#050e2a', bg3: '#030b1a', accent: '#64a0ff', accent2: '#ccff00', stripe1: '#3060cc', stripe2: '#64a0ff', muted: 'rgba(208,228,255,0.6)', chipBg: 'rgba(100,160,255,0.15)', text: '#d0e4ff' },
|
||||
sunset: { bg1: '#120006', bg2: '#1a0808', bg3: '#0e0810', accent: '#ff6020', accent2: '#ff0080', stripe1: '#ff0040', stripe2: '#ffaa00', muted: 'rgba(255,208,160,0.65)', chipBg: 'rgba(255,80,0,0.2)', text: '#ffd0a0' },
|
||||
};
|
||||
|
||||
async function loadImage(url: string): Promise<HTMLImageElement | null> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = CARD_W;
|
||||
canvas.height = CARD_H;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const p = THEME_PALETTE[data.themeId] ?? THEME_PALETTE.thug;
|
||||
|
||||
// ── Background gradient ──
|
||||
const grad = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
|
||||
grad.addColorStop(0, p.bg1);
|
||||
grad.addColorStop(0.6, p.bg2);
|
||||
grad.addColorStop(1, p.bg3);
|
||||
roundRect(ctx, 0, 0, CARD_W, CARD_H, 40);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
|
||||
// ── Top stripe ──
|
||||
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);
|
||||
|
||||
const PAD = 44;
|
||||
|
||||
if (data.type === 'rider') {
|
||||
// ── Brand + label ──
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 28px system-ui, sans-serif';
|
||||
ctx.fillText('🛹 SKATESPOTS', PAD, 72);
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '18px system-ui, sans-serif';
|
||||
ctx.fillText('SKATER LICENSE', PAD, 95);
|
||||
|
||||
// ── npub top-right ──
|
||||
const shortN = `${data.npub.slice(0, 14)}…${data.npub.slice(-6)}`;
|
||||
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.fillStyle = p.muted;
|
||||
ctx.font = '16px system-ui, sans-serif';
|
||||
ctx.fillText(`Since ${data.memberYear}`, CARD_W - PAD, 102);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
// ── Avatar ──
|
||||
const avatarSize = 200;
|
||||
const avatarX = PAD;
|
||||
const avatarY = 120;
|
||||
roundRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 16);
|
||||
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);
|
||||
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 }[];
|
||||
|
||||
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
|
||||
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);
|
||||
|
||||
// ── nip05 ──
|
||||
if (data.nip05) {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = '20px system-ui, sans-serif';
|
||||
ctx.fillText(`@${data.nip05.split('@')[0]}`, idX, 205);
|
||||
}
|
||||
|
||||
// ── 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.fillStyle = p.chipBg;
|
||||
ctx.fill();
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.fillText(data.style.toUpperCase(), idX + 12, 238);
|
||||
}
|
||||
|
||||
// ── Rides for ──
|
||||
if (data.ridesFor) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '18px system-ui, sans-serif';
|
||||
ctx.fillText(`🏪 ${data.ridesFor}`, idX, 270);
|
||||
}
|
||||
|
||||
// ── Sponsors ──
|
||||
if (data.sponsors) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '18px system-ui, sans-serif';
|
||||
ctx.fillText(`🤝 ${data.sponsors}`, idX, data.ridesFor ? 296 : 270);
|
||||
}
|
||||
|
||||
// ── Skill chips ──
|
||||
const skillsToShow = [
|
||||
...data.skills.slice(0, 4).map(id => {
|
||||
const s = SKILL_OPTIONS.find(o => o.id === id);
|
||||
return s ? { label: `${s.icon} ${s.label}`, gold: false } : null;
|
||||
}).filter(Boolean),
|
||||
...data.receivedBadges.slice(0, 2).map(b => {
|
||||
const s = SKILL_OPTIONS.find(o => o.id === b.skill);
|
||||
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';
|
||||
ctx.fill();
|
||||
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
|
||||
}
|
||||
} catch { /* skip QR if fails */ }
|
||||
|
||||
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';
|
||||
|
||||
} else {
|
||||
// ── PARK ACCESS card ──
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 28px system-ui, sans-serif';
|
||||
ctx.fillText(`🏟️ ${(data.issuerName || 'SKATEPARK').toUpperCase()}`, PAD, 72);
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '18px system-ui, sans-serif';
|
||||
ctx.fillText('ACCESS PASS', PAD, 95);
|
||||
|
||||
// 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);
|
||||
ctx.fillStyle = p.chipBg;
|
||||
ctx.fill();
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.fillText((data.accessLevel || 'FULL ACCESS').toUpperCase(), CARD_W - PAD - alW + 14, 76);
|
||||
|
||||
// Avatar
|
||||
const avatarSize = 200;
|
||||
const avatarX = PAD;
|
||||
const avatarY = 120;
|
||||
roundRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 16);
|
||||
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);
|
||||
ctx.clip();
|
||||
ctx.drawImage(img, avatarX, avatarY, avatarSize, avatarSize);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// Recipient name
|
||||
const idX = PAD + avatarSize + 36;
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 46px system-ui, sans-serif';
|
||||
ctx.fillText((data.recipientName || 'MEMBER NAME').toUpperCase(), idX, 175);
|
||||
|
||||
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);
|
||||
|
||||
if (data.validThru) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '18px system-ui, sans-serif';
|
||||
ctx.fillText('Valid thru', idX, 270);
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 28px system-ui, sans-serif';
|
||||
ctx.fillText(data.validThru, idX, 298);
|
||||
}
|
||||
|
||||
// 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)}`;
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '15px 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);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
import {
|
||||
Printer, Download, Copy, Palette, Check, Building2,
|
||||
User, Shield, Award, Zap,
|
||||
|
|
@ -189,7 +553,7 @@ function RiderFront({ d }: { d: CardData }) {
|
|||
<div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none">
|
||||
🛹 SkateSpots
|
||||
</div>
|
||||
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">Rider License</div>
|
||||
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">Skater License</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">npub</div>
|
||||
|
|
@ -612,8 +976,8 @@ function BadgeEventRow({ event }: { event: NostrEvent }) {
|
|||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
export default function RiderCardPage() {
|
||||
useSeoMeta({
|
||||
title: 'Rider Card — SkateSpots',
|
||||
description: 'Create your printable SkateSpots Rider License or Park Access Card.',
|
||||
title: 'Skater Card — SkateSpots',
|
||||
description: 'Create your printable SkateSpots Skater License or Park Access Card.',
|
||||
});
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
|
|
@ -709,42 +1073,43 @@ export default function RiderCardPage() {
|
|||
validThru,
|
||||
};
|
||||
|
||||
// ── Render card to canvas → returns a PNG data URL ───────────────────────
|
||||
// ── Render card via Canvas 2D API → returns a PNG data URL ──────────────
|
||||
const renderCardToImage = useCallback(async (): Promise<string | null> => {
|
||||
if (!printRef.current) return null;
|
||||
setRendering(true);
|
||||
try {
|
||||
// Temporarily disable holographic animation for clean capture
|
||||
const cardEl = printRef.current;
|
||||
cardEl.classList.remove('rider-card-holo');
|
||||
|
||||
const canvas = await html2canvas(cardEl, {
|
||||
scale: 4, // 4× = ~1344×848px — crisp for print
|
||||
useCORS: true, // allow cross-origin avatar images
|
||||
allowTaint: true,
|
||||
backgroundColor: null,
|
||||
logging: false,
|
||||
// html2canvas renders <canvas> elements by copying their pixel data
|
||||
// (works with QRCodeCanvas since it draws synchronously via qrcode lib)
|
||||
onclone: (_doc, el) => {
|
||||
// Ensure the cloned element has exact dimensions matching the original
|
||||
el.style.width = '340px';
|
||||
el.style.height = '214px';
|
||||
el.style.overflow = 'hidden';
|
||||
},
|
||||
const dataUrl = await drawCardToCanvas({
|
||||
type: cardData.type,
|
||||
themeId: cardData.theme,
|
||||
name: cardData.name,
|
||||
nip05: cardData.nip05,
|
||||
avatarUrl: cardData.avatar,
|
||||
pubkey: cardData.pubkey,
|
||||
npub: cardData.npub,
|
||||
style: cardData.style,
|
||||
skills: cardData.skills,
|
||||
sponsors: cardData.sponsors,
|
||||
ridesFor: cardData.ridesFor,
|
||||
showSpots: cardData.showSpots,
|
||||
showZaps: cardData.showZaps,
|
||||
showEvents: cardData.showEvents,
|
||||
spotsAdded: cardData.spotsAdded,
|
||||
zapsReceived: cardData.zapsReceived,
|
||||
eventsCreated: cardData.eventsCreated,
|
||||
memberYear: cardData.memberYear,
|
||||
receivedBadges: cardData.receivedBadges,
|
||||
issuerName: cardData.issuerName,
|
||||
recipientName: cardData.recipientName,
|
||||
accessLevel: cardData.accessLevel,
|
||||
validThru: cardData.validThru,
|
||||
});
|
||||
|
||||
// Restore holo class if it was on
|
||||
if (cardData.holographic) cardEl.classList.add('rider-card-holo');
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
return dataUrl;
|
||||
} catch (err) {
|
||||
console.error('Card render failed:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setRendering(false);
|
||||
}
|
||||
}, [printRef, cardData.holographic]);
|
||||
}, [cardData]);
|
||||
|
||||
// ── Print: render to image, open print window with the image ─────────────
|
||||
const handlePrint = useCallback(async () => {
|
||||
|
|
@ -859,9 +1224,9 @@ export default function RiderCardPage() {
|
|||
<span className="neon-red">SKATE</span>
|
||||
<span className="text-white"> CARDS</span>
|
||||
</h1>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Print-ready credit-card size (85.6 × 54 mm). Cryptographically signed on Nostr.
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Print-ready credit-card size (85.6 × 54 mm). QR-verified via Nostr.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Inner tab nav */}
|
||||
|
|
@ -967,7 +1332,7 @@ export default function RiderCardPage() {
|
|||
<Label className="mb-2 block text-xs uppercase tracking-widest text-gray-500">Card Type</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
{ id: 'rider', label: 'Rider License', icon: User },
|
||||
{ id: 'rider', label: 'Skater License', icon: User },
|
||||
{ id: 'park', label: 'Park Access', icon: Building2 },
|
||||
] as const).map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
|
|
|
|||
Loading…
Reference in a new issue