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:
JP Scott
2026-03-04 22:26:17 -07:00
parent 2ac2c4b2d4
commit dc1ad4d0c0
36 changed files with 1892 additions and 144 deletions

View File

@@ -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">

View File

@@ -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>

View 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">
&ldquo;{existingName}&rdquo; 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>
)
}

View 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>
)
}