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
196 lines
6.5 KiB
TypeScript
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;
|