Use when implementing QR code scanning - provides complete patterns for camera-based scanning, classifying scanned content (Bitcoin addresses, Lightning invoices, npubs, Cashu tokens), handling camera permissions, and paste from clipboard functionality
Complete implementation guide for QR code scanning with camera-based detection and content classification. Supports Bitcoin addresses, Lightning invoices, Lightning addresses, Nostr npubs, and Cashu tokens.
Core Capabilities:
IMPORTANT: Before adding dependencies, review your project's package.json to check if any of these packages already exist. If they do, verify the versions are compatible with the requirements below. Only add packages that are missing or need version updates.
Required packages:
{
"html5-qrcode": "^2.3.8"
}
html5-qrcode)CRITICAL: Classify scanned content before processing to route to correct handlers.
// hooks/useQRCodeScanner.ts
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: 'unknown'; value: string };
export function classifyQRCode(decodedText: string): QRScanResult {
// Lightning invoice: lnbc... or lightning:lnbc...
const lightningInvoiceMatch = decodedText.match(/^(?:lightning:)?(lnbc[a-z0-9]+)$/i);
if (lightningInvoiceMatch) {
return { type: 'lightning_invoice', value: lightningInvoiceMatch[1] };
}
// Lightning address: [email protected] or lightning:[email protected]
const lightningAddressMatch = decodedText.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+/=_-]+$/.test(decodedText)) {
return { type: 'cashu_token', value: decodedText };
}
// Bitcoin address: bc1... or legacy (1..., 3...)
if (/^(bc1|3|1)[a-zA-Z0-9]{25,62}$/.test(decodedText)) {
return { type: 'bitcoin_address', value: decodedText };
}
// Nostr npub: npub1... or nostr:npub1...
const npubMatch = decodedText.match(/^(?:nostr:)?(npub1[a-z0-9]{58})$/);
if (npubMatch) {
return { type: 'npub', value: npubMatch[1] };
}
return { type: 'unknown', value: decodedText };
}
Why classify first?
// hooks/useQRCodeScanner.ts
import { useState, useCallback, useRef } from 'react';
import { Html5Qrcode } from 'html5-qrcode';
// optional import { useToast } from '@/hooks/useToast';
import type { QRScanResult } from './useQRCodeScanner';
export function useQRCodeScanner() {
const [isScanning, setIsScanning] = useState(false);
const [error, setError] = useState<string | null>(null);
const scannerRef = useRef<Html5Qrcode | null>(null);
const isStartingRef = useRef(false);
const shouldCancelRef = useRef(false);
// Optional: User feedback notifications
// Option 1: Console logging
// const logMessage = (message: string) => console.log(message);
// Option 2: Toast notifications (if useToast hook is available)
// const { toast } = useToast();
// Option 3: No notification handler
const startScanning = useCallback(async (
onResult: (result: QRScanResult) => void
): Promise<void> => {
try {
isStartingRef.current = true;
shouldCancelRef.current = false;
setError(null);
// Find available cameras
const devices = await Html5Qrcode.getCameras();
if (devices.length === 0) {
throw new Error('No cameras found');
}
// Check if cancelled during camera detection
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 (requires element with id="qr-reader")
const scanner = new Html5Qrcode('qr-reader');
scannerRef.current = scanner;
// Start scanning
await scanner.start(
cameraId,
{
fps: 10,
aspectRatio: 1,
qrbox: 250
},
(decodedText) => {
// Classify and handle result
const result = classifyQRCode(decodedText);
onResult(result);
},
(errorMessage) => {
// Ignore "QR code not found" errors (normal during scanning)
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;
}
// Provide helpful error messages
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);
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.error('Camera Error:', errorMessage);
// Option 2: Toast notification (if toast is available)
// toast({ variant: "destructive", title: "Camera Error", description: errorMessage });
// Option 3: No notification (silent failure)
}
}, []);
const stopScanning = useCallback(async (): Promise<void> => {
try {
// Handle cancellation during startup
if (isStartingRef.current) {
shouldCancelRef.current = true;
// Wait for startup to complete
let attempts = 0;
while (isStartingRef.current && attempts < 50) {
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
}
if (scannerRef.current) {
const scanner = scannerRef.current;
// Check scanner state before stopping
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,
};
}
// components/QRScanner.tsx
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
// optional import { useToast } from '@/hooks/useToast';
import type { QRScanResult } from '@/hooks/useQRCodeScanner';
interface QRScannerProps {
isOpen: boolean;
onClose: () => Promise<void>;
onResult: (result: QRScanResult) => void;
classifyQRCode: (text: string) => QRScanResult;
startScanning: (onResult: (result: QRScanResult) => void) => Promise<void>;
stopScanning: () => Promise<void>;
setModalOpen?: (isOpen: boolean) => void;
qrError: string | null;
}
export function QRScanner({
isOpen,
onClose,
onResult,
classifyQRCode,
startScanning,
stopScanning,
setModalOpen,
qrError,
}: QRScannerProps) {
// Optional: User feedback notifications
// Option 1: Console logging
// const logMessage = (message: string) => console.log(message);
// Option 2: Toast notifications (if useToast hook is available)
// const { toast } = useToast();
// Option 3: No notification handler
// Handle paste from clipboard
const handlePasteQRCode = async () => {
try {
const text = await navigator.clipboard.readText();
if (!text || !text.trim()) {
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.error('Clipboard Empty: No text found in clipboard');
// Option 2: Toast notification (if toast is available)
// toast({ variant: "destructive", title: "Clipboard Empty", description: "No text found in clipboard" });
// Option 3: No notification (silent failure)
return;
}
const result = classifyQRCode(text.trim());
onResult(result);
await stopScanning();
await onClose();
} catch (err) {
console.error('Error pasting from clipboard:', err);
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.error('Paste Failed: Could not read from clipboard');
// Option 2: Toast notification (if toast is available)
// toast({ variant: "destructive", title: "Paste Failed", description: "Could not read from clipboard" });
// Option 3: No notification (silent failure)
}
};
// Start/stop scanning based on isOpen
useEffect(() => {
if (isOpen) {
setModalOpen?.(true);
startScanning(onResult);
} else {
setModalOpen?.(false);
stopScanning();
}
}, [isOpen, startScanning, stopScanning, setModalOpen, onResult]);
return (
<Dialog open={isOpen} onOpenChange={async (open) => {
if (!open) {
await onClose();
}
}}>
<DialogContent className="max-w-[min(90vw,500px)] w-full p-4 sm:p-6">
<DialogHeader>
<DialogTitle>Scan QR Code</DialogTitle>
<VisuallyHidden.Root asChild>
<DialogDescription>
Position the QR code within the camera view to scan
</DialogDescription>
</VisuallyHidden.Root>
</DialogHeader>
<div className="relative w-full" style={{ aspectRatio: '1 / 1' }}>
{/* Scanner requires element with id="qr-reader" */}
<div
id="qr-reader"
className="w-full h-full rounded-xl overflow-hidden bg-muted"
/>
{/* Error overlay */}
{qrError && (
<div className="absolute inset-x-4 top-4 p-3 bg-destructive text-destructive-foreground rounded-lg text-center z-50 shadow-lg">
{qrError}
</div>
)}
</div>
{/* Paste from clipboard button */}
<div className="mt-4 flex justify-center">
<Button
onClick={handlePasteQRCode}
className="h-12 px-12 rounded-full"
size="lg"
>
Paste
</Button>
</div>
</DialogContent>
</Dialog>
);
}
Key Features:
CRITICAL: Always provide paste functionality as an alternative to camera scanning. Users may have QR codes in text format or camera may be unavailable.
// In QRScanner component
const handlePasteQRCode = async () => {
try {
const text = await navigator.clipboard.readText();
if (!text || !text.trim()) {
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.error('Clipboard Empty: No text found in clipboard');
// Option 2: Toast notification (if toast is available)
// toast({ variant: "destructive", title: "Clipboard Empty", description: "No text found in clipboard" });
// Option 3: No notification (silent failure)
return;
}
// Classify and process pasted content
const result = classifyQRCode(text.trim());
onResult(result);
await stopScanning();
await onClose();
} catch (err) {
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.error('Paste Failed: Could not read from clipboard');
// Option 2: Toast notification (if toast is available)
// toast({ variant: "destructive", title: "Paste Failed", description: "Could not read from clipboard" });
// Option 3: No notification (silent failure)
}
};
Why paste is essential:
// Handle scanned Bitcoin address
function handleQRResult(result: QRScanResult) {
if (result.type === 'bitcoin_address') {
// Validate address format
if (isValidBitcoinAddress(result.value)) {
setBitcoinRecipient(result.value);
setShowSendModal(true);
} else {
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.error('Invalid Address: Scanned address is not valid');
// Option 2: Toast notification (if toast is available)
// toast({ variant: "destructive", title: "Invalid Address", description: "Scanned address is not valid" });
// Option 3: No notification (silent failure)
}
}
}
if (result.type === 'lightning_invoice') {
// Decode invoice to get amount
const decoded = decodeLightningInvoice(result.value);
setLightningInvoice(result.value);
setDecodedInvoiceAmount(decoded.amount);
setShowPayModal(true);
}
if (result.type === 'lightning_address') {
// Validate and process Lightning address
setLightningAddress(result.value);
setShowPayModal(true);
}
if (result.type === 'cashu_token') {
// Process Cashu token
setCashuToken(result.value);
setShowRedeemModal(true);
}
if (result.type === 'npub') {
try {
// Convert npub to Bitcoin address if needed
const address = npubToBitcoinAddress(result.value);
setBitcoinRecipient(address);
setShowSendModal(true);
} catch {
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.error('Invalid npub: Could not decode npub');
// Option 2: Toast notification (if toast is available)
// toast({ variant: "destructive", title: "Invalid npub", description: "Could not decode npub" });
// Option 3: No notification (silent failure)
}
}
if (result.type === 'unknown') {
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.error('Unknown Content: Could not classify scanned QR code');
// Option 2: Toast notification (if toast is available)
// toast({ variant: "destructive", title: "Unknown Content", description: "Could not classify scanned QR code" });
// Option 3: No notification (silent failure)
// Or show raw content for user to decide
setRawContent(result.value);
setShowUnknownContentModal(true);
}
Problem: Html5Qrcode requires a DOM element with id="qr-reader" to mount the scanner.
Solution: Always include the element before calling startScanning:
<div id="qr-reader" className="w-full" />
Problem: Browser blocks camera access without user permission, causing errors.
Solution: Provide clear error messages and instructions:
if (errorMessage.includes('NotAllowedError')) {
errorMessage = 'Camera permission denied. Please allow camera access.';
}
Problem: Scanner continues running after component unmounts, causing memory leaks.
Solution: Always stop scanner in cleanup:
useEffect(() => {
if (isOpen) {
startScanning(onResult);
}
return () => {
stopScanning(); // Cleanup on unmount
};
}, [isOpen]);
Problem: Camera may be unavailable, denied, or users may have QR code as text. Without paste, users are blocked.
Solution: Always include paste from clipboard functionality:
const handlePasteQRCode = async () => {
const text = await navigator.clipboard.readText();
const result = classifyQRCode(text.trim());
onResult(result);
await stopScanning();
await onClose();
};
Why: Paste provides accessibility and fallback when camera fails.
Problem: Processing unknown content types causes errors.
Solution: Always classify first, then route:
const result = classifyQRCode(decodedText);
if (result.type === 'bitcoin_address') {
// Handle Bitcoin address
} else if (result.type === 'lightning_invoice') {
// Handle Lightning invoice
}
Problem: Trying to stop scanner while it's starting causes errors.
Solution: Use refs to track state and handle cancellation:
const isStartingRef = useRef(false);
const shouldCancelRef = useRef(false);
// Check state before operations
if (isStartingRef.current) {
shouldCancelRef.current = true;
// Wait for startup to complete
}
Problem: Scanner may trigger multiple times for the same QR code.
Solution: Debounce or prevent duplicate scans:
const lastScannedRef = useRef<string>('');
const handleScan = (result: QRScanResult) => {
if (lastScannedRef.current === result.value) {
return; // Ignore duplicate scan
}
lastScannedRef.current = result.value;
onResult(result);
};
import { describe, it, expect } from 'vitest';
import { classifyQRCode } from '@/hooks/useQRCodeScanner';
describe('classifyQRCode', () => {
it('classifies Bitcoin addresses', () => {
const result = classifyQRCode('bc1p...');
expect(result.type).toBe('bitcoin_address');
});
it('classifies Lightning invoices', () => {
const result = classifyQRCode('lnbc123...');
expect(result.type).toBe('lightning_invoice');
});
it('handles lightning: prefix', () => {
const result = classifyQRCode('lightning:lnbc123...');
expect(result.type).toBe('lightning_invoice');
expect(result.value).toBe('lnbc123...');
});
it('classifies Lightning addresses', () => {
const result = classifyQRCode('[email protected]');
expect(result.type).toBe('lightning_address');
});
it('classifies Cashu tokens', () => {
const result = classifyQRCode('cashuA...');
expect(result.type).toBe('cashu_token');
});
it('classifies npubs', () => {
const result = classifyQRCode('npub1abc...');
expect(result.type).toBe('npub');
});
it('handles nostr: prefix', () => {
const result = classifyQRCode('nostr:npub1abc...');
expect(result.type).toBe('npub');
expect(result.value).toBe('npub1abc...');
});
it('returns unknown for unrecognized content', () => {
const result = classifyQRCode('random text');
expect(result.type).toBe('unknown');
});
});
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useQRCodeScanner } from '@/hooks/useQRCodeScanner';
import { Html5Qrcode } from 'html5-qrcode';
vi.mock('html5-qrcode');
describe('useQRCodeScanner', () => {
beforeEach(() => {
vi.mocked(Html5Qrcode.getCameras).mockResolvedValue([
{ id: 'camera1', label: 'Back Camera' }
]);
});
afterEach(() => {
vi.clearAllMocks();
});
it('starts scanning successfully', async () => {
const mockScanner = {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
getState: vi.fn().mockReturnValue(2), // SCANNING
};
vi.mocked(Html5Qrcode).mockImplementation(() => mockScanner as any);
const { result } = renderHook(() => useQRCodeScanner());
await result.current.startScanning(vi.fn());
expect(mockScanner.start).toHaveBeenCalled();
expect(result.current.isScanning).toBe(true);
});
it('handles camera permission errors', async () => {
vi.mocked(Html5Qrcode.getCameras).mockRejectedValue(
new Error('NotAllowedError: Permission denied')
);
const { result } = renderHook(() => useQRCodeScanner());
await result.current.startScanning(vi.fn());
expect(result.current.error).toContain('Camera permission denied');
expect(result.current.isScanning).toBe(false);
});
it('stops scanning correctly', async () => {
const mockScanner = {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
getState: vi.fn().mockReturnValue(2), // SCANNING
};
vi.mocked(Html5Qrcode).mockImplementation(() => mockScanner as any);
const { result } = renderHook(() => useQRCodeScanner());
await result.current.startScanning(vi.fn());
await result.current.stopScanning();
expect(mockScanner.stop).toHaveBeenCalled();
expect(mockScanner.clear).toHaveBeenCalled();
expect(result.current.isScanning).toBe(false);
});
});
To implement QR code scanning:
html5-qrcodeHtml5Qrcode with camera accessKey principle: Classify scanned content before processing to ensure correct routing and validation.