- 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>
294 lines
8.9 KiB
TypeScript
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>
|
|
)
|
|
}
|