diff --git a/packages/ui/src/components/qr-scanner.css b/packages/ui/src/components/qr-scanner.css new file mode 100644 index 00000000..8e4709b5 --- /dev/null +++ b/packages/ui/src/components/qr-scanner.css @@ -0,0 +1,29 @@ +/* Scanner corner bracket breathing pulse */ +@keyframes scan-breathe { + 0%, 100% { opacity: 0.5; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.05); } +} + +/* Error shake */ +@keyframes scan-shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-4px); } + 40% { transform: translateX(4px); } + 60% { transform: translateX(-3px); } + 80% { transform: translateX(2px); } +} + +@utility animate-scan-breathe { + animation: scan-breathe 2s ease-in-out infinite; +} + +@utility animate-scan-shake { + animation: scan-shake 0.4s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .animate-scan-breathe, + .animate-scan-shake { + animation: none; + } +} diff --git a/packages/ui/src/hooks/use-qr-scanner.ts b/packages/ui/src/hooks/use-qr-scanner.ts index e54b0b68..15e4ec86 100644 --- a/packages/ui/src/hooks/use-qr-scanner.ts +++ b/packages/ui/src/hooks/use-qr-scanner.ts @@ -3,6 +3,11 @@ import { useRef, useState, useEffect, useCallback } from "react" import type QrScannerLib from "qr-scanner" +export interface Point { + x: number + y: number +} + export interface UseQrScannerOptions { onScan: (data: string) => void onError?: (error: string) => void @@ -14,25 +19,34 @@ export interface UseQrScannerResult { isScanning: boolean error: string | null hasCamera: boolean + cornerPoints: Point[] | null + hasFlash: boolean + toggleFlash: () => Promise + start: () => Promise + stop: () => void + pause: () => void } /** * Hook wrapping qr-scanner lifecycle. * * - Dynamically imports qr-scanner (keeps it out of SSR bundles) - * - Creates/destroys scanner instance based on `enabled` + * - Creates/destroys scanner instance based on `enabled` or manual start/stop + * - Exposes cornerPoints from scan results, flash control, and lifecycle methods * - Releases camera stream on cleanup */ export function useQrScanner({ onScan, onError, - enabled = true, + enabled = false, }: UseQrScannerOptions): UseQrScannerResult { const videoRef = useRef(null) const scannerRef = useRef(null) const [isScanning, setIsScanning] = useState(false) const [error, setError] = useState(null) const [hasCamera, setHasCamera] = useState(true) + const [cornerPoints, setCornerPoints] = useState(null) + const [hasFlash, setHasFlash] = useState(false) // Stable callback refs to avoid re-creating scanner on every render const onScanRef = useRef(onScan) @@ -52,67 +66,128 @@ export function useQrScanner({ return () => { cancelled = true } }, []) - // Start/stop scanner based on `enabled` and video element + const createScanner = useCallback(async () => { + if (!videoRef.current || !hasCamera) return + + const mod = await import("qr-scanner") + const QrScanner = mod.default + + // Destroy previous instance if any + if (scannerRef.current) { + scannerRef.current.stop() + scannerRef.current.destroy() + scannerRef.current = null + } + + const scanner = new QrScanner( + videoRef.current, + (result) => { + const points = result.cornerPoints as Point[] | undefined + setCornerPoints(points?.length ? points : null) + onScanRef.current(result.data) + }, + { + preferredCamera: "environment", + maxScansPerSecond: 5, + returnDetailedScanResult: true, + highlightScanRegion: false, + highlightCodeOutline: false, + onDecodeError: (err) => { + // "No QR code found" fires every frame — ignore it + if (typeof err === "string" && err.includes("No QR code found")) return + console.warn("[QrScanner] decode error:", err) + }, + }, + ) + + scannerRef.current = scanner + return scanner + }, [hasCamera]) + + const start = useCallback(async () => { + setError(null) + setCornerPoints(null) + + try { + let scanner = scannerRef.current + if (!scanner) { + scanner = (await createScanner()) ?? null + if (!scanner) return + } + + await scanner.start() + setIsScanning(true) + + // Check flash availability after camera starts + try { + const flash = await scanner.hasFlash() + setHasFlash(flash) + } catch { + setHasFlash(false) + } + } catch (err) { + const msg = (err as Error).message || "Camera access failed" + setError(msg) + setIsScanning(false) + onErrorRef.current?.(msg) + } + }, [createScanner]) + + const stop = useCallback(() => { + if (scannerRef.current) { + scannerRef.current.stop() + scannerRef.current.destroy() + scannerRef.current = null + } + setIsScanning(false) + setCornerPoints(null) + setHasFlash(false) + }, []) + + const pause = useCallback(() => { + scannerRef.current?.pause() + setIsScanning(false) + }, []) + + const toggleFlash = useCallback(async () => { + if (!scannerRef.current) return + try { + await scannerRef.current.toggleFlash() + } catch { + // Flash not supported or other error — silently ignore + } + }, []) + + // Auto-start/stop based on `enabled` prop (backwards compatible) useEffect(() => { - if (!enabled || !videoRef.current || !hasCamera) return - - let destroyed = false - const video = videoRef.current - - import("qr-scanner").then((mod) => { - if (destroyed) return - const QrScanner = mod.default - - const scanner = new QrScanner( - video, - (result) => { - console.log("[QrScanner] scanned:", result.data) - onScanRef.current(result.data) - }, - { - preferredCamera: "environment", - maxScansPerSecond: 5, - returnDetailedScanResult: true, - highlightScanRegion: false, - highlightCodeOutline: false, - onDecodeError: (err) => { - // "No QR code found" fires every frame — ignore it - if (typeof err === "string" && err.includes("No QR code found")) return - console.warn("[QrScanner] decode error:", err) - }, - }, - ) - - scannerRef.current = scanner - - scanner - .start() - .then(() => { - if (!destroyed) { - console.log("[QrScanner] started successfully") - setIsScanning(true) - setError(null) - } - }) - .catch((err: Error) => { - if (destroyed) return - const msg = err.message || "Camera access failed" - setError(msg) - setIsScanning(false) - onErrorRef.current?.(msg) - }) - }) + if (enabled) { + start() + } else if (!enabled && scannerRef.current) { + stop() + } + }, [enabled, start, stop]) + // Cleanup on unmount + useEffect(() => { return () => { - destroyed = true if (scannerRef.current) { scannerRef.current.stop() scannerRef.current.destroy() scannerRef.current = null } - setIsScanning(false) } - }, [enabled, hasCamera]) + }, []) - return { videoRef, isScanning, error, hasCamera } + return { + videoRef, + isScanning, + error, + hasCamera, + cornerPoints, + hasFlash, + toggleFlash, + start, + stop, + pause, + } }