SkateSpots/src/hooks/useQRCodeScanner.ts
shakespeare.diy feddbaf5ef Add optional CLINK (noffer) self-custodial payments to Lab profiles + shops
Phase 1 CLINK integration (recognize & display only):
- types.ts: optional noffer field + isValidNoffer guard on SkateCoach,
  SkateFilmer, SkateEditor, SkatePhotographer, SkateShop; parsers read it
- NofferField: reusable opt-in input with live validation, added to all 4
  Become-* Lab forms and both Create/Edit shop forms
- ClinkPayButton: secondary "Pay via CLINK" action (QR + copy + nostr: deep
  link), rendered on CoachDetailPage and ShopDetailPage only when a valid
  noffer is present; never replaces the existing zap / Lightning Address flow
- useQRCodeScanner: classify scanned noffer1/ndebit1 CLINK codes
- NIP.md: document the noffer tag on Lab kinds + shops and a CLINK section

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 12:34:50 +07:00

193 lines
6 KiB
TypeScript

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<string | null>(null);
const scannerRef = useRef<Html5QrcodeType | null>(null);
const isStartingRef = useRef(false);
const shouldCancelRef = useRef(false);
const startScanning = useCallback(async (
onResult: (result: QRScanResult) => void
): Promise<void> => {
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<void> => {
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,
};
}