241 lines
7.1 KiB
TypeScript
241 lines
7.1 KiB
TypeScript
import React, { useEffect, useId, useRef, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Html5Qrcode } from 'html5-qrcode';
|
|
import { FaCamera, FaTimes } from 'react-icons/fa';
|
|
import { parseQrPayloadToAppPath } from '../utils/qrNavigation';
|
|
|
|
export interface QRScannerProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
/** Override default navigation; receive original QR text + parsed in-app path */
|
|
onScan?: (decodedText: string, appPath: string | null) => void;
|
|
title?: string;
|
|
/** Prefer rear camera on phones */
|
|
facingMode?: 'environment' | 'user';
|
|
}
|
|
|
|
/**
|
|
* Camera QR scanner modal. On success, navigates inside the SPA (including mobile webview)
|
|
* using React Router so users stay in the app instead of opening an external browser.
|
|
*/
|
|
const QRScanner: React.FC<QRScannerProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
onScan,
|
|
title = 'Scan QR Code',
|
|
facingMode = 'environment',
|
|
}) => {
|
|
const navigate = useNavigate();
|
|
const reactId = useId();
|
|
const readerId = `qr-reader-${reactId.replace(/:/g, '')}`;
|
|
const scannerRef = useRef<Html5Qrcode | null>(null);
|
|
const handledRef = useRef(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [starting, setStarting] = useState(false);
|
|
|
|
const stopScanner = async () => {
|
|
const scanner = scannerRef.current;
|
|
scannerRef.current = null;
|
|
if (!scanner) return;
|
|
try {
|
|
if (scanner.isScanning) {
|
|
await scanner.stop();
|
|
}
|
|
scanner.clear();
|
|
} catch {
|
|
// ignore stop/clear errors when camera already released
|
|
}
|
|
};
|
|
|
|
const handleDecoded = async (decodedText: string) => {
|
|
if (handledRef.current) return;
|
|
handledRef.current = true;
|
|
|
|
const appPath = parseQrPayloadToAppPath(decodedText);
|
|
|
|
if (onScan) {
|
|
await stopScanner();
|
|
onClose();
|
|
onScan(decodedText, appPath);
|
|
return;
|
|
}
|
|
|
|
if (appPath) {
|
|
await stopScanner();
|
|
onClose();
|
|
// In-app navigation — works in mobile webview / PWA without leaving the app
|
|
navigate(appPath);
|
|
return;
|
|
}
|
|
|
|
handledRef.current = false;
|
|
setError(`Unrecognized QR code: ${decodedText}`);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
let cancelled = false;
|
|
handledRef.current = false;
|
|
setError(null);
|
|
setStarting(true);
|
|
|
|
const start = async () => {
|
|
try {
|
|
await stopScanner();
|
|
if (cancelled) return;
|
|
|
|
const scanner = new Html5Qrcode(readerId);
|
|
scannerRef.current = scanner;
|
|
|
|
await scanner.start(
|
|
{ facingMode },
|
|
{
|
|
fps: 10,
|
|
qrbox: (viewfinderWidth, viewfinderHeight) => {
|
|
const edge = Math.min(viewfinderWidth, viewfinderHeight);
|
|
const size = Math.max(180, Math.floor(edge * 0.7));
|
|
return { width: size, height: size };
|
|
},
|
|
aspectRatio: 1,
|
|
},
|
|
(decodedText) => {
|
|
void handleDecoded(decodedText);
|
|
},
|
|
() => {
|
|
// ignore frame-level "not found" callbacks
|
|
}
|
|
);
|
|
} catch (err) {
|
|
console.error('QR scanner start failed:', err);
|
|
if (!cancelled) {
|
|
const message =
|
|
err instanceof Error ? err.message : 'Could not open camera';
|
|
setError(
|
|
message.includes('NotAllowedError') || message.toLowerCase().includes('permission')
|
|
? 'Camera permission denied. Please allow camera access and try again.'
|
|
: `Camera unavailable: ${message}`
|
|
);
|
|
}
|
|
} finally {
|
|
if (!cancelled) setStarting(false);
|
|
}
|
|
};
|
|
|
|
// Small delay so the reader DOM node is mounted
|
|
const timer = window.setTimeout(() => {
|
|
void start();
|
|
}, 100);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearTimeout(timer);
|
|
void stopScanner();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isOpen, readerId, facingMode]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return createPortal(
|
|
<div className="fixed inset-0 z-[10050] flex items-center justify-center bg-black/70 p-4">
|
|
<div className="w-full max-w-md overflow-hidden rounded-xl bg-white shadow-2xl dark:bg-gray-800">
|
|
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700">
|
|
<h3 className="flex items-center gap-2 text-base font-semibold text-gray-900 dark:text-white">
|
|
<FaCamera className="text-teal-600" />
|
|
{title}
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
void stopScanner().then(onClose);
|
|
}}
|
|
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
aria-label="Close scanner"
|
|
>
|
|
<FaTimes />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-3 p-4">
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
Point the camera at an asset QR code. You will stay inside the app.
|
|
</p>
|
|
|
|
{starting && !error && (
|
|
<p className="text-center text-sm text-teal-600 dark:text-teal-400">Starting camera…</p>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
id={readerId}
|
|
className="overflow-hidden rounded-lg bg-black [&_video]:max-h-[60vh] [&_video]:w-full [&_video]:object-cover"
|
|
/>
|
|
</div>
|
|
|
|
<div className="border-t border-gray-200 px-4 py-3 dark:border-gray-700">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
void stopScanner().then(onClose);
|
|
}}
|
|
className="w-full rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
};
|
|
|
|
export interface ScanQRButtonProps {
|
|
className?: string;
|
|
label?: string;
|
|
title?: string;
|
|
onScan?: (decodedText: string, appPath: string | null) => void;
|
|
}
|
|
|
|
/**
|
|
* Reusable Scan QR trigger + scanner modal. Use anywhere you need camera QR → in-app navigation.
|
|
*/
|
|
export const ScanQRButton: React.FC<ScanQRButtonProps> = ({
|
|
className,
|
|
label = 'Scan QR',
|
|
title,
|
|
onScan,
|
|
}) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsOpen(true)}
|
|
className={
|
|
className ||
|
|
'bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg flex items-center justify-center gap-2 shadow transition-all w-full sm:w-auto'
|
|
}
|
|
>
|
|
<FaCamera />
|
|
<span className="font-medium">{label}</span>
|
|
</button>
|
|
<QRScanner
|
|
isOpen={isOpen}
|
|
onClose={() => setIsOpen(false)}
|
|
onScan={onScan}
|
|
title={title}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default QRScanner;
|