- 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>
151 lines
4.9 KiB
TypeScript
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>
|
|
)
|
|
}
|