Add My Bar, Bartender, Recommend features + drink images

- Drink Images: upload/display photos of bottles/cans on drink cards and detail pages
- My Bar: inventory tracker for spirits, liqueurs, mixers, bitters, garnishes, tools
- Bartender: AI-powered cocktail recipe generation, "what can I make" suggestions,
  saved recipes. Cross-references bar inventory for ingredient availability.
- Recommend: AI flavor profile analysis, personalized drink recommendations,
  "find similar" drinks based on highly-rated favorites
- Navigation: desktop sidebar with all 8 routes, mobile bottom nav with
  4 primary items + "More" popup menu
- New Prisma models: BarItem, Recipe, FlavorProfile
- Backup/restore updated to include bar items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JP Scott
2026-03-01 18:28:02 -07:00
parent d8f069cce4
commit 2ac2c4b2d4
40 changed files with 3709 additions and 11 deletions

243
src/app/(app)/bar/page.tsx Normal file
View File

@@ -0,0 +1,243 @@
"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 { Plus, Wine } 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 [editingItem, setEditingItem] = useState<BarItem | null>(null)
const { data, isLoading, error } = useBarItems()
const createBarItem = useCreateBarItem()
const updateBarItem = useUpdateBarItem()
const deleteBarItem = useDeleteBarItem()
function handleCreate(formData: BarItemCreate) {
createBarItem.mutate(formData, {
onSuccess: () => {
setAddDialogOpen(false)
},
})
}
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>
<Button onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Item
</Button>
</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>
{/* Add Item Dialog */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[550px]">
<DialogHeader>
<DialogTitle>Add Bar Item</DialogTitle>
<DialogDescription>
Add a spirit, mixer, or other item to your bar inventory.
</DialogDescription>
</DialogHeader>
<BarItemForm
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,
}}
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>
)
}