localgreenchain/components/mobile/QRScanner.tsx
Claude c2a1b05677
Implement Agent 10: Mobile Optimization with PWA capabilities
This implements the mobile optimization agent (P3 - Enhancement) with:

PWA Configuration:
- Add next-pwa integration with offline caching strategies
- Create web app manifest for installability
- Add service worker with background sync support
- Create offline fallback page

Mobile Components:
- BottomNav: Touch-friendly bottom navigation bar
- MobileHeader: Responsive header with back navigation
- InstallPrompt: Smart PWA install prompt (iOS & Android)
- SwipeableCard: Gesture-based swipeable cards
- PullToRefresh: Native-like pull to refresh
- QRScanner: Camera-based QR code scanning

Mobile Library:
- camera.ts: Camera access and photo capture utilities
- offline.ts: IndexedDB-based offline storage and sync
- gestures.ts: Touch gesture detection (swipe, pinch, tap)
- pwa.ts: PWA status, install prompts, service worker management

Mobile Pages:
- /m: Mobile dashboard with quick actions and stats
- /m/scan: QR code scanner for plant lookup
- /m/quick-add: Streamlined plant registration form
- /m/profile: User profile with offline status

Dependencies added: next-pwa, idb
2025-11-23 03:56:30 +00:00

196 lines
6.5 KiB
TypeScript

import * as React from 'react';
import classNames from 'classnames';
interface QRScannerProps {
onScan: (result: string) => void;
onError?: (error: Error) => void;
onClose?: () => void;
className?: string;
}
export function QRScanner({ onScan, onError, onClose, className }: QRScannerProps) {
const videoRef = React.useRef<HTMLVideoElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [isScanning, setIsScanning] = React.useState(false);
const [hasCamera, setHasCamera] = React.useState(true);
const [cameraError, setCameraError] = React.useState<string | null>(null);
const streamRef = React.useRef<MediaStream | null>(null);
const startCamera = React.useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
setIsScanning(true);
}
} catch (err) {
const error = err as Error;
setHasCamera(false);
setCameraError(error.message);
onError?.(error);
}
}, [onError]);
const stopCamera = React.useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
setIsScanning(false);
}, []);
React.useEffect(() => {
startCamera();
return () => stopCamera();
}, [startCamera, stopCamera]);
// Simple QR detection simulation (in production, use a library like jsQR)
React.useEffect(() => {
if (!isScanning) return;
const scanInterval = setInterval(() => {
if (videoRef.current && canvasRef.current) {
const canvas = canvasRef.current;
const video = videoRef.current;
const ctx = canvas.getContext('2d');
if (ctx && video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// In production, use jsQR library here:
// const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// const code = jsQR(imageData.data, imageData.width, imageData.height);
// if (code) {
// stopCamera();
// onScan(code.data);
// }
}
}
}, 100);
return () => clearInterval(scanInterval);
}, [isScanning, onScan, stopCamera]);
// Demo function to simulate a scan
const simulateScan = () => {
stopCamera();
onScan('plant:abc123-tomato-heirloom');
};
if (!hasCamera) {
return (
<div className={classNames('flex flex-col items-center justify-center p-8 bg-gray-100 rounded-lg', className)}>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-16 w-16 text-gray-400 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p className="text-gray-600 text-center mb-2">Camera access denied</p>
<p className="text-sm text-gray-500 text-center">{cameraError}</p>
<button
onClick={startCamera}
className="mt-4 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Try again
</button>
</div>
);
}
return (
<div className={classNames('relative overflow-hidden rounded-lg bg-black', className)}>
{/* Video feed */}
<video
ref={videoRef}
className="w-full h-full object-cover"
playsInline
muted
/>
{/* Hidden canvas for image processing */}
<canvas ref={canvasRef} className="hidden" />
{/* Scanning overlay */}
<div className="absolute inset-0 flex items-center justify-center">
{/* Darkened corners */}
<div className="absolute inset-0 bg-black/50" />
{/* Transparent scanning area */}
<div className="relative w-64 h-64">
{/* Cut out the scanning area */}
<div className="absolute inset-0 border-2 border-white rounded-lg" />
{/* Corner markers */}
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-green-500 rounded-tl-lg" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-green-500 rounded-tr-lg" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-green-500 rounded-bl-lg" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-green-500 rounded-br-lg" />
{/* Scanning line animation */}
<div className="absolute top-0 left-0 right-0 h-0.5 bg-green-500 animate-scan-line" />
</div>
</div>
{/* Instructions */}
<div className="absolute bottom-20 left-0 right-0 text-center">
<p className="text-white text-sm bg-black/50 inline-block px-4 py-2 rounded-full">
Point camera at QR code
</p>
</div>
{/* Demo scan button (remove in production) */}
<button
onClick={simulateScan}
className="absolute bottom-4 left-1/2 transform -translate-x-1/2 px-6 py-2 bg-green-600 text-white rounded-full text-sm font-medium shadow-lg"
>
Demo: Simulate Scan
</button>
{/* Close button */}
{onClose && (
<button
onClick={() => {
stopCamera();
onClose();
}}
className="absolute top-4 right-4 w-10 h-10 bg-black/50 rounded-full flex items-center justify-center"
aria-label="Close scanner"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
);
}
export default QRScanner;