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>
193 lines
6 KiB
TypeScript
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,
|
|
};
|
|
}
|