Files
drinktracker/src/components/bar/barcode-scanner.tsx
JP Scott dc1ad4d0c0 Add recipes, images, AI photo ID, barcode scanning & ingredient matching
- Fuzzy ingredient matching for bar inventory against recipes
- AI photo identification API for bottles/labels (drink + bar context)
- Barcode scanner with photo toggle for My Bar
- Barcode scan + photo ID buttons on Add Drink form
- Auto-pull product images from Open Food Facts barcode lookup
- Recipes section on drink detail pages with bar availability
- Dedicated Recipes page in sidebar navigation
- Bar item image support (schema, upload, display)
- Drink detail image upload component
- MinIO image proxy through Next.js rewrites (fixes broken image links)
- Improved category mapping (energy drinks → Mixers, not Spirits)
- Re-process saved recipe ingredients against current bar inventory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:26:17 -07:00

151 lines
4.9 KiB
TypeScript

"use client"
import { useEffect, useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import { Camera, X, AlertCircle } from "lucide-react"
interface BarcodeScannerProps {
onScan: (barcode: string) => void
onClose: () => void
}
export function BarcodeScanner({ onScan, onClose }: BarcodeScannerProps) {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const scannerRef = useRef<unknown>(null)
const stoppedRef = useRef(false)
const onScanRef = useRef(onScan)
onScanRef.current = onScan
const containerRef = useRef<string>("barcode-reader-" + Math.random().toString(36).slice(2))
useEffect(() => {
let mounted = true
stoppedRef.current = false
async function startScanner() {
try {
// Dynamic import to avoid SSR issues
const { Html5Qrcode } = await import("html5-qrcode")
if (!mounted) return
const scanner = new Html5Qrcode(containerRef.current)
scannerRef.current = scanner
await scanner.start(
{ facingMode: "environment" },
{
fps: 10,
qrbox: { width: 250, height: 100 },
aspectRatio: 1.0,
},
(decodedText) => {
// Prevent double-fire
if (stoppedRef.current) return
stoppedRef.current = true
// Mark scanner as handled so cleanup doesn't double-stop
scannerRef.current = null
// Stop camera then report result
scanner.stop().then(() => {
if (mounted) onScanRef.current(decodedText)
}).catch(() => {
if (mounted) onScanRef.current(decodedText)
})
},
() => {
// Per-frame decode failure — ignore
}
)
if (mounted) setLoading(false)
} catch (err) {
if (!mounted) return
setLoading(false)
const isInsecure = typeof window !== "undefined" && window.location.protocol === "http:" && window.location.hostname !== "localhost"
if (err instanceof Error) {
if (err.message.includes("Permission") || err.message.includes("NotAllowedError")) {
setError(
isInsecure
? "Camera blocked — HTTPS is required. Access this page via https:// (port 3000) to use the scanner."
: "Camera permission denied. Please allow camera access and try again."
)
} else if (err.message.includes("NotFoundError") || err.message.includes("Requested device not found")) {
setError("No camera found on this device.")
} else if (isInsecure) {
setError("Camera requires HTTPS. Access this page via https:// (port 3000) to use the scanner.")
} else {
setError("Could not start camera. Please try again.")
}
} else if (isInsecure) {
setError("Camera requires HTTPS. Access this page via https:// (port 3000) to use the scanner.")
} else {
setError("Could not start camera. Please try again.")
}
}
}
startScanner()
return () => {
mounted = false
// Only stop if the decode callback didn't already stop it
try {
const scanner = scannerRef.current as { stop?: () => Promise<void> } | null
if (scanner?.stop) {
scanner.stop().catch(() => {})
}
} catch {
// Scanner already stopped or disposed
}
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="relative">
{/* Close button */}
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 z-10 h-8 w-8 p-0 bg-black/50 hover:bg-black/70 text-white rounded-full"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
{/* Scanner viewport */}
<div className="relative rounded-lg overflow-hidden bg-black min-h-[300px]">
<div id={containerRef.current} className="w-full" />
{loading && !error && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-white">
<Camera className="h-8 w-8 animate-pulse" />
<p className="text-sm">Starting camera...</p>
</div>
)}
</div>
{/* Instruction text */}
{!error && (
<p className="text-center text-sm text-muted-foreground mt-3">
Point your camera at a barcode on a bottle or can
</p>
)}
{/* Error state */}
{error && (
<div className="flex flex-col items-center gap-3 py-4">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive text-center">{error}</p>
<Button variant="outline" size="sm" onClick={onClose}>
Close
</Button>
</div>
)}
</div>
)
}