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>
This commit is contained in:
150
src/components/bar/barcode-scanner.tsx
Normal file
150
src/components/bar/barcode-scanner.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user