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:
@@ -48,6 +48,15 @@ interface BarItemCardProps {
|
||||
export function BarItemCard({ item, onEdit, onDelete }: BarItemCardProps) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
{item.imageUrl && (
|
||||
<div className="relative w-full h-32 overflow-hidden rounded-t-lg">
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold leading-tight line-clamp-2">
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectOption } from "@/components/ui/select"
|
||||
import { DrinkImageUpload } from "@/components/drinks/drink-image-upload"
|
||||
import { Barcode } from "lucide-react"
|
||||
import type { BarItemCreate } from "@/lib/validators"
|
||||
|
||||
const CATEGORIES = [
|
||||
@@ -25,7 +27,7 @@ const QUANTITIES = [
|
||||
]
|
||||
|
||||
interface BarItemFormProps {
|
||||
initialData?: Partial<BarItemCreate>
|
||||
initialData?: Partial<BarItemCreate> & { imageUrl?: string | null }
|
||||
onSubmit: (data: BarItemCreate) => void
|
||||
isSubmitting?: boolean
|
||||
submitLabel?: string
|
||||
@@ -41,8 +43,13 @@ export function BarItemForm({
|
||||
const [category, setCategory] = useState(initialData?.category || "SPIRITS")
|
||||
const [quantity, setQuantity] = useState(initialData?.quantity || "FULL")
|
||||
const [notes, setNotes] = useState(initialData?.notes || "")
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(
|
||||
initialData?.imageUrl || null
|
||||
)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const barcode = initialData?.barcode
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -65,6 +72,8 @@ export function BarItemForm({
|
||||
}
|
||||
|
||||
if (notes.trim()) data.notes = notes.trim()
|
||||
if (barcode) data.barcode = barcode
|
||||
if (imageUrl) data.imageUrl = imageUrl
|
||||
|
||||
onSubmit(data)
|
||||
}
|
||||
@@ -84,6 +93,12 @@ export function BarItemForm({
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive">{errors.name}</p>
|
||||
)}
|
||||
{barcode && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Barcode className="h-3 w-3" />
|
||||
<span>UPC: {barcode}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -120,6 +135,11 @@ export function BarItemForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Photo</Label>
|
||||
<DrinkImageUpload imageUrl={imageUrl} onImageChange={setImageUrl} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="bar-item-notes">Notes</Label>
|
||||
|
||||
310
src/components/bar/barcode-scan-dialog.tsx
Normal file
310
src/components/bar/barcode-scan-dialog.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { BarcodeScanner } from "./barcode-scanner"
|
||||
import { CameraCapture } from "@/components/scan/camera-capture"
|
||||
import { useBarcodeLookup } from "@/hooks/use-barcode-lookup"
|
||||
import type { BarcodeLookupResult } from "@/hooks/use-barcode-lookup"
|
||||
import { useIdentifyProduct } from "@/hooks/use-identify"
|
||||
import { Loader2, AlertCircle, PackageCheck, CheckCircle2, ScanLine, Camera } from "lucide-react"
|
||||
|
||||
interface BarcodeScanDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onResult: (result: BarcodeLookupResult) => void
|
||||
}
|
||||
|
||||
type ScanMode = "barcode" | "photo"
|
||||
type Phase = "scanning" | "looking-up" | "found" | "already-exists" | "error"
|
||||
|
||||
export function BarcodeScanDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onResult,
|
||||
}: BarcodeScanDialogProps) {
|
||||
const [scanMode, setScanMode] = useState<ScanMode>("barcode")
|
||||
const [phase, setPhase] = useState<Phase>("scanning")
|
||||
const [scannedBarcode, setScannedBarcode] = useState<string | null>(null)
|
||||
const [existingName, setExistingName] = useState<string | null>(null)
|
||||
const [foundResult, setFoundResult] = useState<BarcodeLookupResult | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const lookup = useBarcodeLookup()
|
||||
const identify = useIdentifyProduct()
|
||||
|
||||
const lookupRef = useRef(lookup)
|
||||
lookupRef.current = lookup
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const t = setTimeout(() => {
|
||||
setPhase("scanning")
|
||||
setScanMode("barcode")
|
||||
setScannedBarcode(null)
|
||||
setExistingName(null)
|
||||
setFoundResult(null)
|
||||
setErrorMessage(null)
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function handleAddToBar() {
|
||||
if (!foundResult) return
|
||||
const result = foundResult
|
||||
onOpenChange(false)
|
||||
setTimeout(() => {
|
||||
onResult(result)
|
||||
}, 250)
|
||||
}
|
||||
|
||||
const handleScan = useCallback(async (barcode: string) => {
|
||||
console.log("[scan-dialog] barcode detected:", barcode)
|
||||
setScannedBarcode(barcode)
|
||||
setPhase("looking-up")
|
||||
|
||||
try {
|
||||
const result = await lookupRef.current.mutateAsync(barcode)
|
||||
console.log("[scan-dialog] lookup result:", result)
|
||||
|
||||
if (result.source === "existing") {
|
||||
setExistingName(result.name)
|
||||
setPhase("already-exists")
|
||||
return
|
||||
}
|
||||
|
||||
setFoundResult(result)
|
||||
setPhase("found")
|
||||
} catch (err) {
|
||||
console.error("[scan-dialog] lookup error:", err)
|
||||
setErrorMessage("Could not identify this barcode. You can try again or add the item manually.")
|
||||
setPhase("error")
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handlePhotoCapture(file: File) {
|
||||
setPhase("looking-up")
|
||||
|
||||
try {
|
||||
// Convert file to base64
|
||||
const buffer = await file.arrayBuffer()
|
||||
const base64 = btoa(
|
||||
new Uint8Array(buffer).reduce((data, byte) => data + String.fromCharCode(byte), "")
|
||||
)
|
||||
|
||||
const result = await identify.mutateAsync({
|
||||
imageBase64: base64,
|
||||
mimeType: file.type || "image/jpeg",
|
||||
context: "bar",
|
||||
})
|
||||
|
||||
// Convert IdentifyResult to BarcodeLookupResult format
|
||||
const lookupResult: BarcodeLookupResult = {
|
||||
name: result.name,
|
||||
brand: result.brewery || null,
|
||||
category: result.category || null,
|
||||
barcode: "",
|
||||
source: "ai" as const,
|
||||
}
|
||||
|
||||
setFoundResult(lookupResult)
|
||||
setPhase("found")
|
||||
} catch (err) {
|
||||
console.error("[scan-dialog] photo identify error:", err)
|
||||
setErrorMessage("Could not identify this product from the photo. Try a clearer image or add the item manually.")
|
||||
setPhase("error")
|
||||
}
|
||||
}
|
||||
|
||||
function switchMode(mode: ScanMode) {
|
||||
setScanMode(mode)
|
||||
setPhase("scanning")
|
||||
setErrorMessage(null)
|
||||
}
|
||||
|
||||
const dialogTitle = scanMode === "barcode" ? "Scan Barcode" : "Identify by Photo"
|
||||
const dialogDesc =
|
||||
phase === "scanning"
|
||||
? scanMode === "barcode"
|
||||
? "Point your camera at the barcode on a bottle or can."
|
||||
: "Take a photo of the bottle or label."
|
||||
: phase === "looking-up"
|
||||
? scanMode === "barcode" ? "Looking up the product..." : "Identifying the product..."
|
||||
: phase === "found"
|
||||
? "Product identified!"
|
||||
: phase === "already-exists"
|
||||
? "This item is already in your bar."
|
||||
: "Something went wrong."
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogDescription>{dialogDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Mode toggle — only show during scanning phase */}
|
||||
{phase === "scanning" && (
|
||||
<div className="flex rounded-lg border overflow-hidden">
|
||||
<button
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
scanMode === "barcode"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted/50 text-muted-foreground hover:bg-muted"
|
||||
}`}
|
||||
onClick={() => switchMode("barcode")}
|
||||
>
|
||||
<ScanLine className="h-4 w-4" />
|
||||
Scan Barcode
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
scanMode === "photo"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted/50 text-muted-foreground hover:bg-muted"
|
||||
}`}
|
||||
onClick={() => switchMode("photo")}
|
||||
>
|
||||
<Camera className="h-4 w-4" />
|
||||
Take Photo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scanning phase */}
|
||||
{phase === "scanning" && scanMode === "barcode" && (
|
||||
<BarcodeScanner
|
||||
onScan={handleScan}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === "scanning" && scanMode === "photo" && (
|
||||
<CameraCapture
|
||||
onCapture={handlePhotoCapture}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Looking up phase */}
|
||||
{phase === "looking-up" && (
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<div className="text-center">
|
||||
<p className="font-medium">
|
||||
{scanMode === "barcode" ? "Looking up product..." : "AI is identifying the product..."}
|
||||
</p>
|
||||
{scannedBarcode && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Barcode: {scannedBarcode}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Found phase */}
|
||||
{phase === "found" && foundResult && (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<CheckCircle2 className="h-8 w-8 text-green-500" />
|
||||
<div className="text-center">
|
||||
<p className="font-medium">Product found!</p>
|
||||
<p className="text-lg font-semibold mt-1">
|
||||
{foundResult.brand && foundResult.name
|
||||
? `${foundResult.brand} ${foundResult.name}`
|
||||
: foundResult.name || "Unknown product"}
|
||||
</p>
|
||||
{foundResult.category && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Category: {foundResult.category}
|
||||
</p>
|
||||
)}
|
||||
{scannedBarcode && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
UPC: {scannedBarcode}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setFoundResult(null)
|
||||
setScannedBarcode(null)
|
||||
setPhase("scanning")
|
||||
}}
|
||||
>
|
||||
Scan Another
|
||||
</Button>
|
||||
<Button onClick={handleAddToBar}>
|
||||
Add to Bar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Already exists phase */}
|
||||
{phase === "already-exists" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<PackageCheck className="h-8 w-8 text-primary" />
|
||||
<div className="text-center">
|
||||
<p className="font-medium">Already in your bar!</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
“{existingName}” is already in your inventory.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPhase("scanning")}
|
||||
>
|
||||
Scan Another
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error phase */}
|
||||
{phase === "error" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-destructive">
|
||||
{scanMode === "barcode" ? "Lookup failed" : "Identification failed"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{errorMessage || "Something went wrong. Please try again."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setErrorMessage(null)
|
||||
setPhase("scanning")
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
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