diff --git a/src/components/SecuritySettings.tsx b/src/components/SecuritySettings.tsx index 59c46a5..5547d8f 100644 --- a/src/components/SecuritySettings.tsx +++ b/src/components/SecuritySettings.tsx @@ -6,7 +6,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { toast } from '@/hooks/useToast'; -import QRCode from 'qrcode'; + export function SecuritySettings() { const { user } = useCurrentUser(); diff --git a/src/components/SpamFeePayment.tsx b/src/components/SpamFeePayment.tsx index 0d2da57..c6fc363 100644 --- a/src/components/SpamFeePayment.tsx +++ b/src/components/SpamFeePayment.tsx @@ -7,7 +7,7 @@ import { useNWC } from '@/hooks/useNWCContext'; import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress'; import { LISTING_FEE_SATS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions'; import { Zap, Loader2, CheckCircle, Copy, ExternalLink, ShieldAlert, TrendingUp, MapPin } from 'lucide-react'; -import QRCode from 'qrcode'; +// qrcode imported lazily in async function interface SpamFeePaymentProps { feeRecipient: string; @@ -52,6 +52,7 @@ export function SpamFeePayment({ setInvoice(bolt11); // Generate QR code + const { default: QRCode } = await import('qrcode'); const qr = await QRCode.toDataURL(bolt11.toUpperCase(), { width: 256, margin: 2, diff --git a/src/components/ZapDialog.tsx b/src/components/ZapDialog.tsx index 27dd60e..c2388d3 100644 --- a/src/components/ZapDialog.tsx +++ b/src/components/ZapDialog.tsx @@ -35,7 +35,7 @@ import { useCashu } from '@/hooks/wallet/useCashu'; import { useLightningOperations } from '@/hooks/wallet/useLightningOperations'; import { WalletModal } from '@/components/WalletModal'; 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"; interface ZapDialogProps { @@ -443,6 +443,7 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) { } try { + const { default: QRCode } = await import('qrcode'); const url = await QRCode.toDataURL(invoice.toUpperCase(), { width: 512, margin: 2, diff --git a/src/components/auth/MnemonicSignupDialog.tsx b/src/components/auth/MnemonicSignupDialog.tsx index d7b3f0b..1047f8d 100644 --- a/src/components/auth/MnemonicSignupDialog.tsx +++ b/src/components/auth/MnemonicSignupDialog.tsx @@ -14,7 +14,7 @@ import { mnemonicToWords, wordsToMnemonic } from '@/lib/nip06'; -import QRCode from 'qrcode'; +// qrcode imported lazily in async function interface MnemonicSignupDialogProps { isOpen: boolean; @@ -95,6 +95,7 @@ const MnemonicSignupDialog: React.FC = ({ // Generate QR code const handleQR = async () => { try { + const { default: QRCode } = await import('qrcode'); const qrDataUrl = await QRCode.toDataURL(mnemonic, { width: 300, margin: 2, diff --git a/src/components/ui/qrcode.tsx b/src/components/ui/qrcode.tsx index 81df85c..6327e1b 100644 --- a/src/components/ui/qrcode.tsx +++ b/src/components/ui/qrcode.tsx @@ -1,5 +1,4 @@ -import QRCode from 'qrcode'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; interface QRCodeCanvasProps { value: string; @@ -14,11 +13,21 @@ export function QRCodeCanvas({ value, size = 256, level = 'M', className }: QRCo useEffect(() => { if (!canvasRef.current || !value || value.length < 4) return; const el = canvasRef.current; - try { - QRCode.toCanvas(el, value, { width: size, margin: 1, errorCorrectionLevel: level }); - } catch { - // silently skip on invalid input - } + + // Lazy import to avoid crashing at module load in restricted environments + import('qrcode').then((mod) => { + try { + mod.default.toCanvas(el, value, { + width: size, + margin: 1, + errorCorrectionLevel: level, + }); + } catch { + // silently skip + } + }).catch(() => { + // qrcode unavailable in this environment + }); }, [value, size, level]); return ; diff --git a/src/hooks/useQRCodeScanner.ts b/src/hooks/useQRCodeScanner.ts index 1460af1..40fbf96 100644 --- a/src/hooks/useQRCodeScanner.ts +++ b/src/hooks/useQRCodeScanner.ts @@ -1,5 +1,6 @@ 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 = | { type: 'lightning_invoice'; value: string } @@ -49,7 +50,7 @@ export function classifyQRCode(decodedText: string): QRScanResult { export function useQRCodeScanner() { const [isScanning, setIsScanning] = useState(false); const [error, setError] = useState(null); - const scannerRef = useRef(null); + const scannerRef = useRef(null); const isStartingRef = useRef(false); const shouldCancelRef = useRef(false); @@ -61,6 +62,9 @@ export function useQRCodeScanner() { shouldCancelRef.current = false; setError(null); + // Lazy load to avoid canvas crash at module load time + const { Html5Qrcode } = await import('html5-qrcode'); + // Find available cameras const devices = await Html5Qrcode.getCameras(); if (devices.length === 0) { diff --git a/src/lib/wallet/qrCodeGenerator.ts b/src/lib/wallet/qrCodeGenerator.ts index 404c099..67577d8 100644 --- a/src/lib/wallet/qrCodeGenerator.ts +++ b/src/lib/wallet/qrCodeGenerator.ts @@ -1,13 +1,12 @@ -import QRCode from 'qrcode'; - /** * 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 * @returns Promise resolving to data URL */ export async function generateQRCode(text: string): Promise { try { + const { default: QRCode } = await import('qrcode'); const dataUrl = await QRCode.toDataURL(text, { width: 300, margin: 2, diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index 7442e69..54c6152 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -16,7 +16,8 @@ import { useState, useRef, useCallback } from 'react'; import { useSeoMeta } from '@unhead/react'; 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 { LoginArea } from '@/components/auth/LoginArea'; @@ -41,6 +42,21 @@ import { useQuery } from '@tanstack/react-query'; import { genUserName } from '@/lib/genUserName'; import { cn } from '@/lib/utils'; 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 ───────────────────────────────────────────────────── // Draws the card entirely via Canvas 2D API — 100% reliable export, // no CSS capture, no scaling issues, no background-stripping. @@ -192,6 +208,8 @@ async function drawCardToCanvas(data: CanvasCardData): Promise { const qrY = 28; let qrImg: HTMLImageElement | null = null; try { + // Dynamic import — avoids canvas API access at module load time + const QRCode = (await import('qrcode')).default; const qrDataUrl = await QRCode.toDataURL(qrUrl, { width: qrSize * 2, margin: 2, @@ -499,232 +517,6 @@ async function drawCardToCanvas(data: CanvasCardData): Promise { 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 ────────────────────────────────────────────────────────────── export const PARK_ACCESS_CARD_KIND = 35679; // Park issues to rider