'use client'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Loader2, ScanLine } from 'lucide-react'; import { toast } from 'sonner'; import { lookupScannedEan } from '@/app/(app)/katalog/scan-actions'; import { formatEan, isValidEan } from '@/lib/ean'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; type Controls = { stop: () => void }; /** * Scans the barcode on a box with the phone camera. * * Decoding runs in the browser with zxing, which is bundled with the app and * loaded only when the scanner is opened - nothing leaves the device, and the * catalog pages stay light for everyone who never scans. */ export function BarcodeScannerButton() { const router = useRouter(); const videoRef = useRef(null); const controlsRef = useRef(null); const handledRef = useRef(false); const [open, setOpen] = useState(false); const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting'); const [message, setMessage] = useState(null); const stop = useCallback(() => { controlsRef.current?.stop(); controlsRef.current = null; }, []); const onDetected = useCallback( (raw: string) => { if (handledRef.current) return; if (!isValidEan(raw)) return; // keep scanning on a misread handledRef.current = true; stop(); setStatus('checking'); void lookupScannedEan(raw).then((result) => { if (!result.ok) { toast.error(result.error); setOpen(false); return; } setOpen(false); if (result.data.found) { toast.success(`${result.data.setNumber} · ${result.data.name}`); router.push(`/katalog/${result.data.setId}`); } else { toast.info(`${formatEan(result.data.ean)} ist noch nicht im Katalog.`); router.push(`/katalog/neu?ean=${encodeURIComponent(result.data.ean)}`); } router.refresh(); }); }, [router, stop], ); useEffect(() => { if (!open) return; handledRef.current = false; setStatus('starting'); setMessage(null); // The camera needs a secure context - same rule as for passkeys. if (!window.isSecureContext) { setStatus('error'); setMessage( 'Die Kamera funktioniert nur über HTTPS. Ruf Bricktrack über deine Domain auf, nicht über die IP-Adresse.', ); return; } let cancelled = false; void (async () => { try { const { BrowserMultiFormatReader } = await import('@zxing/browser'); const reader = new BrowserMultiFormatReader(); const video = videoRef.current; if (!video || cancelled) return; const controls = await reader.decodeFromConstraints( // The rear camera is the one pointing at the box. { video: { facingMode: 'environment' } }, video, (result) => { if (result) onDetected(result.getText()); }, ); if (cancelled) { controls.stop(); return; } controlsRef.current = controls; setStatus('scanning'); } catch (error) { if (cancelled) return; console.error('[scan] Kamera konnte nicht gestartet werden', error); setStatus('error'); const name = (error as { name?: string })?.name; setMessage( name === 'NotAllowedError' ? 'Der Zugriff auf die Kamera wurde abgelehnt. Erlaube ihn in den Browser-Einstellungen und versuch es noch einmal.' : name === 'NotFoundError' ? 'Dieses Gerät hat keine nutzbare Kamera.' : 'Die Kamera lässt sich nicht starten.', ); } })(); return () => { cancelled = true; stop(); }; }, [open, onDetected, stop]); return ( <> { if (!next) stop(); setOpen(next); }} > Barcode scannen Halte den Strichcode auf der Packung ins Bild. Kennt der Katalog das Set schon, landest du direkt darauf – sonst im Formular mit ausgefüllter EAN. {status === 'error' ? (

{message}

) : (
); }