import { useState, useCallback, useRef } from 'react'; // 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 } | { type: 'lightning_address'; value: string } | { type: 'cashu_token'; value: string } | { type: 'bitcoin_address'; value: string } | { type: 'npub'; value: string } | { type: 'clink_offer'; value: string } | { type: 'clink_debit'; value: string } | { type: 'unknown'; value: string }; /** * Classify scanned QR code content */ export function classifyQRCode(decodedText: string): QRScanResult { const trimmed = decodedText.trim(); // Lightning invoice: lnbc... or lightning:lnbc... const lightningInvoiceMatch = trimmed.match(/^(?:lightning:)?(lnbc[a-z0-9]+)$/i); if (lightningInvoiceMatch) { return { type: 'lightning_invoice', value: lightningInvoiceMatch[1] }; } // Lightning address: name@domain.tld or lightning:name@domain.tld const lightningAddressMatch = trimmed.match(/^(?:lightning:)?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/); if (lightningAddressMatch) { return { type: 'lightning_address', value: lightningAddressMatch[1] }; } // Cashu token: cashu... if (/^cashu[A-Za-z0-9+/=_-]+$/i.test(trimmed)) { return { type: 'cashu_token', value: trimmed }; } // Bitcoin address: bc1... or legacy (1..., 3...) if (/^(bc1|3|1)[a-zA-Z0-9]{25,62}$/.test(trimmed)) { return { type: 'bitcoin_address', value: trimmed }; } // Nostr npub: npub1... or nostr:npub1... const npubMatch = trimmed.match(/^(?:nostr:)?(npub1[a-z0-9]{58})$/); if (npubMatch) { return { type: 'npub', value: npubMatch[1] }; } // CLINK Offer code: noffer1... or nostr:noffer1... (self-custodial static payment code) const nofferMatch = trimmed.match(/^(?:nostr:)?(noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,})$/i); if (nofferMatch) { return { type: 'clink_offer', value: nofferMatch[1].toLowerCase() }; } // CLINK Debit authorization: ndebit1... or nostr:ndebit1... const ndebitMatch = trimmed.match(/^(?:nostr:)?(ndebit1[023456789acdefghjklmnpqrstuvwxyz]{20,})$/i); if (ndebitMatch) { return { type: 'clink_debit', value: ndebitMatch[1].toLowerCase() }; } return { type: 'unknown', value: trimmed }; } export function useQRCodeScanner() { const [isScanning, setIsScanning] = useState(false); const [error, setError] = useState(null); const scannerRef = useRef(null); const isStartingRef = useRef(false); const shouldCancelRef = useRef(false); const startScanning = useCallback(async ( onResult: (result: QRScanResult) => void ): Promise => { try { isStartingRef.current = true; 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) { throw new Error('No cameras found'); } if (shouldCancelRef.current) { isStartingRef.current = false; return; } // Use rear camera by default, or first available const cameraId = devices.length > 1 ? devices.find(d => d.label.toLowerCase().includes('back') || d.label.toLowerCase().includes('rear') )?.id || devices[0].id : devices[0].id; // Initialize scanner const scanner = new Html5Qrcode('qr-reader'); scannerRef.current = scanner; // Start scanning await scanner.start( cameraId, { fps: 10, aspectRatio: 1, qrbox: 250 }, (decodedText) => { const result = classifyQRCode(decodedText); onResult(result); }, (errorMessage) => { // Ignore "QR code not found" errors if (!errorMessage.includes('qr')) { console.debug('QR scan error:', errorMessage); } } ); isStartingRef.current = false; setIsScanning(true); } catch (err) { isStartingRef.current = false; let errorMessage = 'Failed to start camera'; if (err instanceof Error) { errorMessage = err.message; } if (errorMessage.includes('NotAllowedError') || errorMessage.includes('Permission denied')) { errorMessage = 'Camera permission denied. Please allow camera access.'; } else if (errorMessage.includes('NotFoundError') || errorMessage.includes('No device')) { errorMessage = 'No camera found. Please connect a camera.'; } setError(errorMessage); setIsScanning(false); } }, []); const stopScanning = useCallback(async (): Promise => { try { if (isStartingRef.current) { shouldCancelRef.current = true; let attempts = 0; while (isStartingRef.current && attempts < 50) { await new Promise(resolve => setTimeout(resolve, 100)); attempts++; } } if (scannerRef.current) { const scanner = scannerRef.current; const state = scanner.getState(); if (state === 2) { // SCANNING await scanner.stop(); } else if (state === 1) { // STARTING await new Promise(resolve => setTimeout(resolve, 200)); if (scanner.getState() === 2) { await scanner.stop(); } } await scanner.clear(); scannerRef.current = null; } shouldCancelRef.current = false; isStartingRef.current = false; setIsScanning(false); setError(null); } catch (err) { console.error('Error stopping scanner:', err); scannerRef.current = null; setIsScanning(false); } }, []); return { isScanning, error, startScanning, stopScanning, classifyQRCode, }; }