fix: remove orphaned canvas code from RiderCardPage causing ctx/Printer is not defined crash

The drawCardToCanvas function had an old version of the card rendering logic
that leaked outside the function body (lines 505-731 were orphaned code).
This code referenced local variables (ctx, p, data, qrX, qrSize, PAD, etc.)
that only existed inside the function, causing ReferenceError at module load time
and crashing the entire app with a blank screen.

Also added missing lucide-react imports (Printer, Loader2, Share2, Download,
Check, Copy, Award, Zap, Info, Palette, Shield, ChevronUp, ChevronDown) that
were used in the JSX but not imported, causing the secondary Printer is not
defined ReferenceError.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-16 17:02:59 +07:00
parent 125bce3637
commit ebbf2d6326
8 changed files with 50 additions and 243 deletions

View file

@ -6,7 +6,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
import { toast } from '@/hooks/useToast'; import { toast } from '@/hooks/useToast';
import QRCode from 'qrcode';
export function SecuritySettings() { export function SecuritySettings() {
const { user } = useCurrentUser(); const { user } = useCurrentUser();

View file

@ -7,7 +7,7 @@ import { useNWC } from '@/hooks/useNWCContext';
import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress'; import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress';
import { LISTING_FEE_SATS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions'; import { LISTING_FEE_SATS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions';
import { Zap, Loader2, CheckCircle, Copy, ExternalLink, ShieldAlert, TrendingUp, MapPin } from 'lucide-react'; import { Zap, Loader2, CheckCircle, Copy, ExternalLink, ShieldAlert, TrendingUp, MapPin } from 'lucide-react';
import QRCode from 'qrcode'; // qrcode imported lazily in async function
interface SpamFeePaymentProps { interface SpamFeePaymentProps {
feeRecipient: string; feeRecipient: string;
@ -52,6 +52,7 @@ export function SpamFeePayment({
setInvoice(bolt11); setInvoice(bolt11);
// Generate QR code // Generate QR code
const { default: QRCode } = await import('qrcode');
const qr = await QRCode.toDataURL(bolt11.toUpperCase(), { const qr = await QRCode.toDataURL(bolt11.toUpperCase(), {
width: 256, width: 256,
margin: 2, margin: 2,

View file

@ -35,7 +35,7 @@ import { useCashu } from '@/hooks/wallet/useCashu';
import { useLightningOperations } from '@/hooks/wallet/useLightningOperations'; import { useLightningOperations } from '@/hooks/wallet/useLightningOperations';
import { WalletModal } from '@/components/WalletModal'; import { WalletModal } from '@/components/WalletModal';
import type { Event } from 'nostr-tools'; import type { Event } from 'nostr-tools';
import QRCode from 'qrcode'; // qrcode imported lazily in async function to avoid canvas crash at module load
import type { WebLNProvider } from "@webbtc/webln-types"; import type { WebLNProvider } from "@webbtc/webln-types";
interface ZapDialogProps { interface ZapDialogProps {
@ -443,6 +443,7 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
} }
try { try {
const { default: QRCode } = await import('qrcode');
const url = await QRCode.toDataURL(invoice.toUpperCase(), { const url = await QRCode.toDataURL(invoice.toUpperCase(), {
width: 512, width: 512,
margin: 2, margin: 2,

View file

@ -14,7 +14,7 @@ import {
mnemonicToWords, mnemonicToWords,
wordsToMnemonic wordsToMnemonic
} from '@/lib/nip06'; } from '@/lib/nip06';
import QRCode from 'qrcode'; // qrcode imported lazily in async function
interface MnemonicSignupDialogProps { interface MnemonicSignupDialogProps {
isOpen: boolean; isOpen: boolean;
@ -95,6 +95,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
// Generate QR code // Generate QR code
const handleQR = async () => { const handleQR = async () => {
try { try {
const { default: QRCode } = await import('qrcode');
const qrDataUrl = await QRCode.toDataURL(mnemonic, { const qrDataUrl = await QRCode.toDataURL(mnemonic, {
width: 300, width: 300,
margin: 2, margin: 2,

View file

@ -1,5 +1,4 @@
import QRCode from 'qrcode'; import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from 'react';
interface QRCodeCanvasProps { interface QRCodeCanvasProps {
value: string; value: string;
@ -14,11 +13,21 @@ export function QRCodeCanvas({ value, size = 256, level = 'M', className }: QRCo
useEffect(() => { useEffect(() => {
if (!canvasRef.current || !value || value.length < 4) return; if (!canvasRef.current || !value || value.length < 4) return;
const el = canvasRef.current; const el = canvasRef.current;
try {
QRCode.toCanvas(el, value, { width: size, margin: 1, errorCorrectionLevel: level }); // Lazy import to avoid crashing at module load in restricted environments
} catch { import('qrcode').then((mod) => {
// silently skip on invalid input try {
} mod.default.toCanvas(el, value, {
width: size,
margin: 1,
errorCorrectionLevel: level,
});
} catch {
// silently skip
}
}).catch(() => {
// qrcode unavailable in this environment
});
}, [value, size, level]); }, [value, size, level]);
return <canvas ref={canvasRef} width={size} height={size} className={className} />; return <canvas ref={canvasRef} width={size} height={size} className={className} />;

View file

@ -1,5 +1,6 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
import { Html5Qrcode } from 'html5-qrcode'; // Html5Qrcode imported lazily inside startScanning to avoid canvas crash at module load
type Html5QrcodeType = import('html5-qrcode').Html5Qrcode;
export type QRScanResult = export type QRScanResult =
| { type: 'lightning_invoice'; value: string } | { type: 'lightning_invoice'; value: string }
@ -49,7 +50,7 @@ export function classifyQRCode(decodedText: string): QRScanResult {
export function useQRCodeScanner() { export function useQRCodeScanner() {
const [isScanning, setIsScanning] = useState(false); const [isScanning, setIsScanning] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const scannerRef = useRef<Html5Qrcode | null>(null); const scannerRef = useRef<Html5QrcodeType | null>(null);
const isStartingRef = useRef(false); const isStartingRef = useRef(false);
const shouldCancelRef = useRef(false); const shouldCancelRef = useRef(false);
@ -61,6 +62,9 @@ export function useQRCodeScanner() {
shouldCancelRef.current = false; shouldCancelRef.current = false;
setError(null); setError(null);
// Lazy load to avoid canvas crash at module load time
const { Html5Qrcode } = await import('html5-qrcode');
// Find available cameras // Find available cameras
const devices = await Html5Qrcode.getCameras(); const devices = await Html5Qrcode.getCameras();
if (devices.length === 0) { if (devices.length === 0) {

View file

@ -1,13 +1,12 @@
import QRCode from 'qrcode';
/** /**
* Generate a QR code data URL from text * Generate a QR code data URL from text
* * qrcode imported lazily to avoid canvas crash at module load
* @param text - Text to encode in QR code * @param text - Text to encode in QR code
* @returns Promise resolving to data URL * @returns Promise resolving to data URL
*/ */
export async function generateQRCode(text: string): Promise<string> { export async function generateQRCode(text: string): Promise<string> {
try { try {
const { default: QRCode } = await import('qrcode');
const dataUrl = await QRCode.toDataURL(text, { const dataUrl = await QRCode.toDataURL(text, {
width: 300, width: 300,
margin: 2, margin: 2,

View file

@ -16,7 +16,8 @@
import { useState, useRef, useCallback } from 'react'; import { useState, useRef, useCallback } from 'react';
import { useSeoMeta } from '@unhead/react'; import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import QRCode from 'qrcode'; // QRCode is imported lazily inside drawCardToCanvas() to avoid canvas API
// access at module load time (crashes the preview environment)
import { Layout } from '@/components/Layout'; import { Layout } from '@/components/Layout';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
@ -41,6 +42,21 @@ import { useQuery } from '@tanstack/react-query';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
import {
Printer,
Loader2,
Share2,
Download,
Check,
Copy,
Award,
Zap,
Info,
Palette,
Shield,
ChevronUp,
ChevronDown,
} from 'lucide-react';
// ─── Card canvas renderer ───────────────────────────────────────────────────── // ─── Card canvas renderer ─────────────────────────────────────────────────────
// Draws the card entirely via Canvas 2D API — 100% reliable export, // Draws the card entirely via Canvas 2D API — 100% reliable export,
// no CSS capture, no scaling issues, no background-stripping. // no CSS capture, no scaling issues, no background-stripping.
@ -192,6 +208,8 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
const qrY = 28; const qrY = 28;
let qrImg: HTMLImageElement | null = null; let qrImg: HTMLImageElement | null = null;
try { try {
// Dynamic import — avoids canvas API access at module load time
const QRCode = (await import('qrcode')).default;
const qrDataUrl = await QRCode.toDataURL(qrUrl, { const qrDataUrl = await QRCode.toDataURL(qrUrl, {
width: qrSize * 2, width: qrSize * 2,
margin: 2, margin: 2,
@ -499,232 +517,6 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
return canvas.toDataURL('image/png'); return canvas.toDataURL('image/png');
} }
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') {
// ── Header: brand left, npub right ──────────────────────────────────────
ctx.fillStyle = p.accent;
ctx.font = 'bold 30px system-ui, sans-serif';
ctx.fillText('🛹 SKATESPOTS', PAD, 54);
ctx.fillStyle = p.muted;
ctx.font = '17px system-ui, sans-serif';
ctx.fillText('SKATER LICENSE', PAD, 76);
// 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 = '14px monospace';
ctx.fillText(npub1, PAD, 100);
ctx.fillStyle = p.muted;
ctx.fillText(npub2, PAD, 118);
// ── Avatar (square, left column) ────────────────────────────────────────
const avatarSize = 180;
const avatarX = PAD;
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, 14);
ctx.clip();
ctx.drawImage(img, avatarX, avatarY, avatarSize, avatarSize);
ctx.restore();
}
}
// ── Identity column (right of avatar, left of QR) ───────────────────────
const idX = PAD + avatarSize + 28;
const idMaxW = qrX - idX - 16;
let cy = 155; // current Y cursor
// Name
ctx.fillStyle = p.text;
ctx.font = 'bold 46px system-ui, sans-serif';
fillTextFit(ctx, data.name.toUpperCase(), idX, cy, idMaxW);
cy += 52;
// nip05
if (data.nip05) {
ctx.fillStyle = p.accent;
ctx.font = '19px system-ui, sans-serif';
fillTextFit(ctx, `@${data.nip05.split('@')[0]}`, idX, cy, idMaxW);
cy += 26;
}
// Style chip
if (data.style) {
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(chipTxt, idX + 11, cy + 19);
cy += 38;
}
// ── SHOP section (explicit labeled) ─────────────────────────────────────
if (data.ridesFor) {
ctx.fillStyle = p.muted;
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 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';
fillTextFit(ctx, data.sponsors, idX, cy + 18, idMaxW);
cy += 32;
}
// ── 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, 5).map(id => {
const s = SKILL_OPTIONS.find(o => o.id === id);
return s ? { label: `${s.label}`, gold: false } : null;
}).filter(Boolean),
...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;
}).filter(Boolean),
] as { label: string; gold: boolean }[];
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.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(`SINCE ${data.memberYear}`, PAD + avatarSize / 2, sinceY);
ctx.textAlign = 'left';
} else {
// ── PARK ACCESS card ─────────────────────────────────────────────────────
ctx.fillStyle = p.accent;
ctx.font = 'bold 30px system-ui, sans-serif';
ctx.fillText(`🏟️ ${(data.issuerName || 'SKATEPARK').toUpperCase()}`, PAD, 54);
ctx.fillStyle = p.muted;
ctx.font = '17px system-ui, sans-serif';
ctx.fillText('ACCESS PASS', PAD, 76);
// 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(alTxt, PAD + 14, 111);
// Avatar
const avatarSize = 180;
const avatarX = PAD;
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, 14);
ctx.clip();
ctx.drawImage(img, avatarX, avatarY, avatarSize, avatarSize);
ctx.restore();
}
}
const idX = PAD + avatarSize + 28;
const idMaxW = qrX - idX - 16;
let cy = 170;
ctx.fillStyle = p.text;
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 = '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 = '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, cy + 26);
}
}
// ─── Nostr kinds ────────────────────────────────────────────────────────────── // ─── Nostr kinds ──────────────────────────────────────────────────────────────
export const PARK_ACCESS_CARD_KIND = 35679; // Park issues to rider export const PARK_ACCESS_CARD_KIND = 35679; // Park issues to rider