Iman_AMS/asm_app/src/components/QRScanner.tsx

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;