Files
drinktracker/src/app/(app)/bar/page.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

294 lines
8.9 KiB
TypeScript

"use client"
import { Suspense, useState } from "react"
import { Header } from "@/components/layout/header"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog"
import { BarItemForm } from "@/components/bar/bar-item-form"
import { BarCategoryGroup } from "@/components/bar/bar-category-group"
import {
useBarItems,
useCreateBarItem,
useUpdateBarItem,
useDeleteBarItem,
} from "@/hooks/use-bar"
import type { BarItem } from "@/hooks/use-bar"
import { BarcodeScanDialog } from "@/components/bar/barcode-scan-dialog"
import type { BarcodeLookupResult } from "@/hooks/use-barcode-lookup"
import { Plus, Wine, ScanLine } from "lucide-react"
import type { BarItemCreate } from "@/lib/validators"
export default function BarPage() {
return (
<Suspense fallback={<BarLoading />}>
<BarContent />
</Suspense>
)
}
function BarLoading() {
return (
<div>
<Header title="My Bar" />
<div className="p-4 md:p-8 space-y-6">
<Skeleton className="h-8 w-48" />
<div className="space-y-6">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="space-y-3">
<Skeleton className="h-6 w-32" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 4 }, (_, j) => (
<Skeleton key={j} className="h-[120px] rounded-lg" />
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}
const CATEGORY_ORDER = [
"SPIRITS",
"LIQUEURS",
"MIXERS",
"BITTERS",
"GARNISHES",
"TOOLS",
]
function BarContent() {
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [scanDialogOpen, setScanDialogOpen] = useState(false)
const [editingItem, setEditingItem] = useState<BarItem | null>(null)
const [scannedData, setScannedData] = useState<Partial<BarItemCreate> | null>(null)
const { data, isLoading, error } = useBarItems()
const createBarItem = useCreateBarItem()
const updateBarItem = useUpdateBarItem()
const deleteBarItem = useDeleteBarItem()
function handleScanResult(result: BarcodeLookupResult) {
const initial: Partial<BarItemCreate> & { imageUrl?: string } = {
barcode: result.barcode,
}
if (result.name) {
initial.name = result.brand
? `${result.brand} ${result.name}`
: result.name
}
if (result.category) {
initial.category = result.category as BarItemCreate["category"]
}
if (result.imageUrl) {
initial.imageUrl = result.imageUrl
}
setScannedData(initial)
// Scan dialog closes itself before calling this — just open add form
setAddDialogOpen(true)
}
function handleCreate(formData: BarItemCreate) {
createBarItem.mutate(formData, {
onSuccess: () => {
setAddDialogOpen(false)
setScannedData(null)
},
})
}
function handleUpdate(formData: BarItemCreate) {
if (!editingItem) return
updateBarItem.mutate(
{ id: editingItem.id, data: formData },
{
onSuccess: () => {
setEditingItem(null)
},
}
)
}
function handleDelete(item: BarItem) {
if (confirm(`Delete "${item.name}" from your bar?`)) {
deleteBarItem.mutate(item.id)
}
}
// Group items by category
const groupedItems = (data?.items || []).reduce<Record<string, BarItem[]>>(
(groups, item) => {
const key = item.category
if (!groups[key]) groups[key] = []
groups[key].push(item)
return groups
},
{}
)
const totalItems = data?.items.length || 0
return (
<div>
<Header title="My Bar" />
<div className="p-4 md:p-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">My Bar</h1>
<p className="text-muted-foreground">
{totalItems > 0
? `${totalItems} item${totalItems !== 1 ? "s" : ""} in your bar`
: "Your bar inventory"}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setScanDialogOpen(true)}>
<ScanLine className="h-4 w-4 mr-2" />
Scan
</Button>
<Button onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Item
</Button>
</div>
</div>
{isLoading ? (
<div className="space-y-6">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="space-y-3">
<Skeleton className="h-6 w-32" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 4 }, (_, j) => (
<Skeleton key={j} className="h-[120px] rounded-lg" />
))}
</div>
</div>
))}
</div>
) : error ? (
<div className="text-center py-12">
<p className="text-destructive">
Failed to load bar items. Please try again.
</p>
</div>
) : totalItems === 0 ? (
<div className="text-center py-16 space-y-4">
<Wine className="h-12 w-12 mx-auto text-muted-foreground/50" />
<div>
<h3 className="text-lg font-semibold">Your bar is empty</h3>
<p className="text-muted-foreground mt-1">
Add your first item to get started.
</p>
</div>
<Button onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Your First Item
</Button>
</div>
) : (
<div className="space-y-8">
{CATEGORY_ORDER.filter((cat) => groupedItems[cat]?.length > 0).map(
(cat) => (
<BarCategoryGroup
key={cat}
category={cat}
items={groupedItems[cat]}
onEdit={setEditingItem}
onDelete={handleDelete}
/>
)
)}
</div>
)}
</div>
{/* Barcode Scan Dialog */}
<BarcodeScanDialog
open={scanDialogOpen}
onOpenChange={setScanDialogOpen}
onResult={handleScanResult}
/>
{/* Add Item Dialog */}
<Dialog
open={addDialogOpen}
onOpenChange={(open) => {
setAddDialogOpen(open)
if (!open) setScannedData(null)
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[550px]">
<DialogHeader>
<DialogTitle>Add Bar Item</DialogTitle>
<DialogDescription>
{scannedData
? "Review the scanned product info and make any changes."
: "Add a spirit, mixer, or other item to your bar inventory."}
</DialogDescription>
</DialogHeader>
<BarItemForm
key={scannedData?.barcode || "manual"}
initialData={scannedData || undefined}
onSubmit={handleCreate}
isSubmitting={createBarItem.isPending}
submitLabel="Add Item"
/>
{createBarItem.isError && (
<p className="text-sm text-destructive">
{createBarItem.error.message || "Failed to add item"}
</p>
)}
</DialogContent>
</Dialog>
{/* Edit Item Dialog */}
<Dialog
open={editingItem !== null}
onOpenChange={(open) => {
if (!open) setEditingItem(null)
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[550px]">
<DialogHeader>
<DialogTitle>Edit Bar Item</DialogTitle>
<DialogDescription>
Update the details for this item.
</DialogDescription>
</DialogHeader>
{editingItem && (
<>
<BarItemForm
initialData={{
name: editingItem.name,
category: editingItem.category,
quantity: editingItem.quantity,
notes: editingItem.notes || undefined,
barcode: editingItem.barcode || undefined,
imageUrl: editingItem.imageUrl || undefined,
}}
onSubmit={handleUpdate}
isSubmitting={updateBarItem.isPending}
submitLabel="Update Item"
/>
{updateBarItem.isError && (
<p className="text-sm text-destructive">
{updateBarItem.error.message || "Failed to update item"}
</p>
)}
</>
)}
</DialogContent>
</Dialog>
</div>
)
}