+
setOpen(!open)}
+ className={cn(
+ "flex flex-col items-center gap-1 px-3 py-2 text-xs font-medium transition-colors min-w-[64px]",
+ open || isActiveInMore ? "text-primary" : "text-muted-foreground"
+ )}
+ >
+ {open ? (
+
+ ) : (
+
+ )}
+ More
+
+
+ {open && (
+ <>
+ {/* Backdrop */}
+
setOpen(false)}
+ />
+
+ {/* Menu */}
+
+ {moreItems.map((item) => {
+ const isActive = pathname.startsWith(item.href)
+ return (
+ setOpen(false)}
+ className={cn(
+ "flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium transition-colors",
+ isActive
+ ? "bg-primary/10 text-primary"
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
+ )}
+ >
+
+ {item.label}
+
+ )
+ })}
+
+ >
+ )}
+
+ )
+}
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index 487a824..bed30d7 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -11,6 +11,9 @@ import {
Settings,
LogOut,
Beer,
+ FlaskConical,
+ GlassWater,
+ Sparkles,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -19,6 +22,9 @@ const navItems = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/scan", label: "Scan Menu", icon: Camera },
{ href: "/drinks", label: "My Drinks", icon: Wine },
+ { href: "/bar", label: "My Bar", icon: FlaskConical },
+ { href: "/bartender", label: "Bartender", icon: GlassWater },
+ { href: "/recommend", label: "Recommend", icon: Sparkles },
{ href: "/wishlist", label: "Try Later", icon: Bookmark },
{ href: "/settings", label: "Settings", icon: Settings },
]
diff --git a/src/components/recommend/flavor-profile-card.tsx b/src/components/recommend/flavor-profile-card.tsx
new file mode 100644
index 0000000..934e944
--- /dev/null
+++ b/src/components/recommend/flavor-profile-card.tsx
@@ -0,0 +1,252 @@
+"use client"
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Skeleton } from "@/components/ui/skeleton"
+import {
+ RefreshCw,
+ Sparkles,
+ AlertTriangle,
+ ThumbsUp,
+ ThumbsDown,
+ Compass,
+} from "lucide-react"
+import type { FlavorProfile, FlavorProfileData } from "@/hooks/use-recommend"
+
+interface FlavorProfileCardProps {
+ profile: FlavorProfile | null
+ isLoading: boolean
+ isGenerating: boolean
+ error: Error | null
+ generateError: Error | null
+ onGenerate: () => void
+}
+
+export function FlavorProfileCard({
+ profile,
+ isLoading,
+ isGenerating,
+ error,
+ generateError,
+ onGenerate,
+}: FlavorProfileCardProps) {
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ My Flavor Profile
+
+
+
+
+ Failed to load your flavor profile. Please try again.
+
+
+
+ )
+ }
+
+ if (!profile) {
+ return (
+
+
+
+
+ My Flavor Profile
+
+
+ Generate an AI-powered analysis of your taste preferences based on
+ your drink ratings.
+
+
+
+
+
+
+ Rate at least 3 drinks to unlock your personalized flavor profile.
+
+
+ {isGenerating ? (
+ <>
+
+ Analyzing...
+ >
+ ) : (
+ <>
+
+ Generate Profile
+ >
+ )}
+
+
+ {generateError && (
+
+ {generateError.message}
+
+ )}
+
+
+ )
+ }
+
+ const data = profile.profileData as FlavorProfileData | null
+
+ return (
+
+
+
+
+
+
+ My Flavor Profile
+
+
+ Based on {profile.ratingCount} rating
+ {profile.ratingCount !== 1 ? "s" : ""}
+ {" -- "}
+ updated{" "}
+ {new Date(profile.generatedAt).toLocaleDateString()}
+
+
+
+ {isGenerating ? (
+
+ ) : (
+
+ )}
+
+
+ {profile.isStale && (
+
+
+
+ You have {profile.currentRatingCount - profile.ratingCount} new
+ rating{profile.currentRatingCount - profile.ratingCount !== 1 ? "s" : ""}{" "}
+ since your last profile update. Refresh to get an updated profile.
+
+
+ )}
+
+
+ {profile.profileText}
+
+ {data && (
+ <>
+ {data.topFlavors && data.topFlavors.length > 0 && (
+
+
+
+ Flavors You Love
+
+
+ {data.topFlavors.map((flavor) => (
+
+ {flavor}
+
+ ))}
+
+
+ )}
+
+ {data.avoidFlavors && data.avoidFlavors.length > 0 && (
+
+
+
+ Flavors to Avoid
+
+
+ {data.avoidFlavors.map((flavor) => (
+
+ {flavor}
+
+ ))}
+
+
+ )}
+
+ {data.preferredTypes && data.preferredTypes.length > 0 && (
+
+
+ Preferred Styles
+
+
+ {data.preferredTypes.map((type) => (
+ {type}
+ ))}
+
+
+ )}
+
+ {data.adventureScore != null && (
+
+
+
+ Adventure Score
+
+
+
+
+ {Math.round(data.adventureScore * 100)}%
+
+
+
+ {data.adventureScore >= 0.7
+ ? "You love trying new and different things!"
+ : data.adventureScore >= 0.4
+ ? "You have a nice balance between favorites and new discoveries."
+ : "You know what you like and stick to it."}
+
+
+ )}
+ >
+ )}
+
+ {generateError && (
+
+ {generateError.message}
+
+ )}
+
+
+ )
+}
diff --git a/src/components/recommend/recommendation-card.tsx b/src/components/recommend/recommendation-card.tsx
new file mode 100644
index 0000000..7a24faf
--- /dev/null
+++ b/src/components/recommend/recommendation-card.tsx
@@ -0,0 +1,94 @@
+"use client"
+
+import { Card, CardContent } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { cn } from "@/lib/utils"
+
+const TYPE_COLORS: Record
= {
+ BEER: "bg-amber-500/15 text-amber-700 border-amber-500/25",
+ WINE: "bg-rose-500/15 text-rose-700 border-rose-500/25",
+ COCKTAIL: "bg-purple-500/15 text-purple-700 border-purple-500/25",
+ SPIRIT: "bg-sky-500/15 text-sky-700 border-sky-500/25",
+ OTHER: "bg-slate-500/15 text-slate-700 border-slate-500/25",
+}
+
+const TYPE_LABELS: Record = {
+ BEER: "Beer",
+ WINE: "Wine",
+ COCKTAIL: "Cocktail",
+ SPIRIT: "Spirit",
+ OTHER: "Other",
+}
+
+interface RecommendationCardProps {
+ name: string
+ type: string
+ subType?: string
+ brewery?: string
+ reason: string
+ score: number
+ scoreLabel?: string
+}
+
+export function RecommendationCard({
+ name,
+ type,
+ subType,
+ brewery,
+ reason,
+ score,
+ scoreLabel = "Match",
+}: RecommendationCardProps) {
+ const percentage = Math.round(score * 100)
+
+ return (
+
+
+
+
+
{name}
+ {subType && (
+
+ {subType}
+
+ )}
+ {brewery && (
+
{brewery}
+ )}
+
+
+ {TYPE_LABELS[type] || type}
+
+
+
+
+ {reason}
+
+
+
+
+
= 80
+ ? "bg-green-500"
+ : percentage >= 60
+ ? "bg-yellow-500"
+ : "bg-orange-500"
+ )}
+ style={{ width: `${percentage}%` }}
+ />
+
+
+ {percentage}% {scoreLabel}
+
+
+
+
+ )
+}
diff --git a/src/components/recommend/similar-section.tsx b/src/components/recommend/similar-section.tsx
new file mode 100644
index 0000000..1f10d20
--- /dev/null
+++ b/src/components/recommend/similar-section.tsx
@@ -0,0 +1,149 @@
+"use client"
+
+import { useState } from "react"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Select } from "@/components/ui/select"
+import { GitCompareArrows, RefreshCw } from "lucide-react"
+import { RecommendationCard } from "./recommendation-card"
+import { useSimilarDrinks } from "@/hooks/use-recommend"
+import type { SimilarDrink } from "@/hooks/use-recommend"
+
+interface DrinkOption {
+ id: string
+ name: string
+ type: string
+}
+
+interface SimilarSectionProps {
+ drinks: DrinkOption[]
+ drinksLoading: boolean
+}
+
+export function SimilarSection({
+ drinks,
+ drinksLoading,
+}: SimilarSectionProps) {
+ const [selectedDrinkId, setSelectedDrinkId] = useState("")
+ const similarDrinks = useSimilarDrinks()
+ const [results, setResults] = useState
([])
+ const [sourceName, setSourceName] = useState("")
+
+ function handleFindSimilar() {
+ if (!selectedDrinkId) return
+
+ similarDrinks.mutate(
+ { drinkId: selectedDrinkId },
+ {
+ onSuccess: (data) => {
+ setResults(data.recommendations)
+ setSourceName(data.sourceDrink)
+ },
+ }
+ )
+ }
+
+ return (
+
+
+
+
+ Find Similar Drinks
+
+
+ Pick a drink you love and discover similar ones you might enjoy.
+
+
+
+ {drinksLoading ? (
+
+ ) : drinks.length === 0 ? (
+
+
+ Add some drinks to your collection to find similar ones.
+
+
+ ) : (
+ <>
+
+
+ setSelectedDrinkId(e.target.value)}
+ disabled={similarDrinks.isPending}
+ >
+ Select a drink...
+ {drinks.map((drink) => (
+
+ {drink.name} ({drink.type.charAt(0) + drink.type.slice(1).toLowerCase()})
+
+ ))}
+
+
+
+ {similarDrinks.isPending ? (
+ <>
+
+ Searching...
+ >
+ ) : (
+ <>
+
+ Find Similar
+ >
+ )}
+
+
+
+ {similarDrinks.isPending && (
+
+ {Array.from({ length: 3 }, (_, i) => (
+
+ ))}
+
+ )}
+
+ {similarDrinks.isError && (
+
+ {similarDrinks.error.message}
+
+ )}
+
+ {results.length > 0 && !similarDrinks.isPending && (
+ <>
+
+ Drinks similar to {sourceName} :
+
+
+ {results.map((drink, i) => (
+
+ ))}
+
+ >
+ )}
+ >
+ )}
+
+
+ )
+}
diff --git a/src/components/recommend/suggest-section.tsx b/src/components/recommend/suggest-section.tsx
new file mode 100644
index 0000000..db0013e
--- /dev/null
+++ b/src/components/recommend/suggest-section.tsx
@@ -0,0 +1,151 @@
+"use client"
+
+import { useState } from "react"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Lightbulb, RefreshCw } from "lucide-react"
+import { RecommendationCard } from "./recommendation-card"
+import { useSuggestDrinks } from "@/hooks/use-recommend"
+import type { DrinkSuggestion } from "@/hooks/use-recommend"
+
+interface SuggestSectionProps {
+ hasProfile: boolean
+}
+
+export function SuggestSection({ hasProfile }: SuggestSectionProps) {
+ const [mood, setMood] = useState("")
+ const [occasion, setOccasion] = useState("")
+ const suggestDrinks = useSuggestDrinks()
+ const [results, setResults] = useState([])
+
+ function handleSuggest() {
+ suggestDrinks.mutate(
+ {
+ mood: mood.trim() || undefined,
+ occasion: occasion.trim() || undefined,
+ },
+ {
+ onSuccess: (data) => {
+ setResults(data.recommendations)
+ },
+ }
+ )
+ }
+
+ return (
+
+
+
+
+ What Should I Drink?
+
+
+ Get personalized drink suggestions based on your flavor profile.
+ Optionally add your mood or the occasion.
+
+
+
+ {!hasProfile ? (
+
+
+ Generate your flavor profile above to unlock personalized
+ suggestions.
+
+
+ ) : (
+ <>
+
+
+
+ {suggestDrinks.isPending ? (
+ <>
+
+ Finding drinks...
+ >
+ ) : (
+ <>
+
+ Suggest Drinks
+ >
+ )}
+
+
+ {suggestDrinks.isPending && (
+
+ {Array.from({ length: 3 }, (_, i) => (
+
+ ))}
+
+ )}
+
+ {suggestDrinks.isError && (
+
+ {suggestDrinks.error.message}
+
+ )}
+
+ {results.length > 0 && !suggestDrinks.isPending && (
+
+ {results.map((drink, i) => (
+
+ ))}
+
+ )}
+ >
+ )}
+
+
+ )
+}
diff --git a/src/hooks/use-bar.ts b/src/hooks/use-bar.ts
new file mode 100644
index 0000000..6d7f4ec
--- /dev/null
+++ b/src/hooks/use-bar.ts
@@ -0,0 +1,81 @@
+"use client"
+
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
+import type { BarItemCreate, BarItemUpdate } from "@/lib/validators"
+
+export interface BarItem {
+ id: string
+ userId: string
+ name: string
+ category: "SPIRITS" | "LIQUEURS" | "MIXERS" | "BITTERS" | "GARNISHES" | "TOOLS"
+ quantity: "FULL" | "HALF" | "LOW" | "EMPTY"
+ notes: string | null
+ createdAt: string
+ updatedAt: string
+}
+
+export interface BarItemsResponse {
+ items: BarItem[]
+}
+
+async function fetchWithError(url: string, options?: RequestInit) {
+ const res = await fetch(url, options)
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}))
+ throw new Error(body.error || `Request failed with status ${res.status}`)
+ }
+ return res.json()
+}
+
+export function useBarItems() {
+ return useQuery({
+ queryKey: ["bar-items"],
+ queryFn: () => fetchWithError("/api/bar"),
+ })
+}
+
+export function useCreateBarItem() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (data: BarItemCreate) =>
+ fetchWithError("/api/bar", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["bar-items"] })
+ },
+ })
+}
+
+export function useUpdateBarItem() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: ({ id, data }: { id: string; data: BarItemUpdate }) =>
+ fetchWithError(`/api/bar/${id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["bar-items"] })
+ },
+ })
+}
+
+export function useDeleteBarItem() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (id: string) =>
+ fetchWithError(`/api/bar/${id}`, {
+ method: "DELETE",
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["bar-items"] })
+ },
+ })
+}
diff --git a/src/hooks/use-bartender.ts b/src/hooks/use-bartender.ts
new file mode 100644
index 0000000..62ee32f
--- /dev/null
+++ b/src/hooks/use-bartender.ts
@@ -0,0 +1,102 @@
+"use client"
+
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
+import type { RecipeCreate } from "@/lib/validators"
+
+export interface RecipeIngredient {
+ name: string
+ amount: string
+ available: boolean
+}
+
+export interface Recipe {
+ id: string
+ userId: string
+ title: string
+ ingredients: RecipeIngredient[]
+ steps: string[]
+ garnish: string | null
+ glassware: string | null
+ sourceDrinkId: string | null
+ notes: string | null
+ createdAt: string
+ updatedAt: string
+ sourceDrink?: { name: string; type: string } | null
+}
+
+export interface SuggestedCocktail {
+ title: string
+ ingredients: RecipeIngredient[]
+ steps: string[]
+ garnish?: string
+ glassware?: string
+ missingCount: number
+}
+
+async function fetchWithError(url: string, options?: RequestInit) {
+ const res = await fetch(url, options)
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}))
+ throw new Error(body.error || `Request failed with status ${res.status}`)
+ }
+ return res.json()
+}
+
+export function useRecreateRecipe() {
+ return useMutation({
+ mutationFn: (data: { cocktailName: string; drinkId?: string }) =>
+ fetchWithError("/api/bartender/recreate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ }),
+ })
+}
+
+export function useSuggestCocktails() {
+ return useMutation({
+ mutationFn: () =>
+ fetchWithError("/api/bartender/suggest", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ }),
+ })
+}
+
+export function useRecipes() {
+ return useQuery<{ recipes: Recipe[] }>({
+ queryKey: ["recipes"],
+ queryFn: () => fetchWithError("/api/recipes"),
+ })
+}
+
+export function useSaveRecipe() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (data: RecipeCreate) =>
+ fetchWithError("/api/recipes", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["recipes"] })
+ },
+ })
+}
+
+export function useDeleteRecipe() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (id: string) =>
+ fetchWithError(`/api/recipes/${id}`, {
+ method: "DELETE",
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["recipes"] })
+ },
+ })
+}
diff --git a/src/hooks/use-recommend.ts b/src/hooks/use-recommend.ts
new file mode 100644
index 0000000..01f314b
--- /dev/null
+++ b/src/hooks/use-recommend.ts
@@ -0,0 +1,99 @@
+"use client"
+
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
+
+export interface FlavorProfileData {
+ summary: string
+ topFlavors: string[]
+ avoidFlavors: string[]
+ preferredTypes: string[]
+ adventureScore: number
+}
+
+export interface FlavorProfile {
+ id: string
+ profileText: string
+ profileData: FlavorProfileData | null
+ generatedAt: string
+ ratingCount: number
+ isStale: boolean
+ currentRatingCount: number
+}
+
+export interface DrinkSuggestion {
+ name: string
+ type: string
+ subType?: string
+ brewery?: string
+ reason: string
+ matchScore: number
+}
+
+export interface SimilarDrink {
+ name: string
+ type: string
+ subType?: string
+ brewery?: string
+ reason: string
+ similarity: number
+}
+
+async function fetchWithError(url: string, options?: RequestInit) {
+ const res = await fetch(url, options)
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}))
+ throw new Error(body.error || `Request failed with status ${res.status}`)
+ }
+ return res.json()
+}
+
+export function useFlavorProfile() {
+ return useQuery<{ profile: FlavorProfile | null }>({
+ queryKey: ["flavorProfile"],
+ queryFn: () => fetchWithError("/api/recommend/profile"),
+ })
+}
+
+export function useGenerateFlavorProfile() {
+ const queryClient = useQueryClient()
+
+ return useMutation<{ profile: FlavorProfile }>({
+ mutationFn: () =>
+ fetchWithError("/api/recommend/profile", {
+ method: "POST",
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["flavorProfile"] })
+ },
+ })
+}
+
+export function useSuggestDrinks() {
+ return useMutation<
+ { recommendations: DrinkSuggestion[] },
+ Error,
+ { mood?: string; occasion?: string }
+ >({
+ mutationFn: (data) =>
+ fetchWithError("/api/recommend/suggest", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ }),
+ })
+}
+
+export function useSimilarDrinks() {
+ return useMutation<
+ { recommendations: SimilarDrink[]; sourceDrink: string },
+ Error,
+ { drinkId: string }
+ >({
+ mutationFn: (data) =>
+ fetchWithError("/api/recommend/similar", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ }),
+ })
+}
diff --git a/src/lib/ai/prompts.ts b/src/lib/ai/prompts.ts
index 88867c5..5380553 100644
--- a/src/lib/ai/prompts.ts
+++ b/src/lib/ai/prompts.ts
@@ -166,3 +166,119 @@ Do not include any text before or after the JSON array. Example format:
}
]`
}
+
+export const COCKTAIL_RECIPE_PROMPT = `You are an expert bartender and cocktail specialist. Generate a detailed cocktail recipe.
+
+The user's home bar inventory is provided. For each ingredient, indicate whether the user has it.
+
+## User's Bar Inventory
+{barInventory}
+
+## Instructions
+Create a recipe for the requested cocktail. Return a valid JSON object:
+- "title" (string): Cocktail name
+- "ingredients" (array): Each { "name": string, "amount": string, "available": boolean }
+- "steps" (string array): Step-by-step instructions
+- "garnish" (string, optional): Garnish description
+- "glassware" (string, optional): Recommended glass
+- "notes" (string, optional): Tips, variations, or history
+
+Mark ingredients available:true only if matching item exists in bar inventory. Do not include text before or after the JSON.`
+
+export const WHAT_CAN_I_MAKE_PROMPT = `You are an expert bartender. Based on the user's bar inventory, suggest cocktails they can make.
+
+## User's Bar Inventory
+{barInventory}
+
+## Instructions
+Suggest cocktails prioritizing those where ALL ingredients are available, then those missing 1-2 ingredients.
+
+Return a valid JSON array of objects:
+- "title" (string): Cocktail name
+- "ingredients" (array): Each { "name": string, "amount": string, "available": boolean }
+- "steps" (string array): Brief preparation steps
+- "garnish" (string, optional)
+- "glassware" (string, optional)
+- "missingCount" (number): How many ingredients missing (0 = can make now)
+
+Sort by missingCount ascending. Return up to 10. Do not include text before or after the JSON.`
+
+export function buildBarInventoryString(items: { name: string; category: string; quantity: string }[]): string {
+ const byCategory: Record = {}
+ for (const item of items) {
+ if (item.quantity === "EMPTY") continue
+ if (!byCategory[item.category]) byCategory[item.category] = []
+ byCategory[item.category].push(`${item.name} (${item.quantity.toLowerCase()})`)
+ }
+ return Object.entries(byCategory)
+ .map(([cat, items]) => `### ${cat}\n${items.map(i => `- ${i}`).join('\n')}`)
+ .join('\n\n') || 'No items in bar inventory.'
+}
+
+export const FLAVOR_PROFILE_PROMPT = `You are an expert sommelier and drink taste profiler. Analyze the user's drink history and ratings to build a flavor profile.
+
+## User's Rated Drinks
+{drinkHistory}
+
+## Instructions
+Create a comprehensive flavor profile. Return a valid JSON object:
+- "summary" (string): 2-3 sentence natural language summary
+- "topFlavors" (string array): Top 5-8 flavor descriptors they prefer
+- "avoidFlavors" (string array): Flavors they tend to dislike
+- "preferredTypes" (string array): Preferred drink types/styles
+- "adventureScore" (number, 0-1): How adventurous/varied their choices are
+
+Do not include text before or after the JSON.`
+
+export const RECOMMEND_DRINK_PROMPT = `You are a drink recommendation expert. Based on the user's flavor profile and context, suggest drinks they'd enjoy.
+
+## User's Flavor Profile
+{flavorProfile}
+
+## Additional Context
+{context}
+
+## Instructions
+Recommend 3-5 drinks. Return a valid JSON array of objects:
+- "name" (string): Specific drink name
+- "type" (string): BEER, WINE, COCKTAIL, SPIRIT, or OTHER
+- "subType" (string, optional): Style
+- "brewery" (string, optional): Producer
+- "reason" (string): 1-2 sentence personalized explanation
+- "matchScore" (number, 0-1): How well it matches their profile
+
+Sort by matchScore descending. Do not include text before or after the JSON.`
+
+export const SIMILAR_DRINK_PROMPT = `You are a drink expert. The user loves a specific drink and wants similar ones.
+
+## Drink They Love
+{sourceDrink}
+
+## User's Flavor Profile
+{flavorProfile}
+
+## Instructions
+Suggest 3-5 similar drinks. Return a valid JSON array of objects:
+- "name" (string): Specific drink name
+- "type" (string): BEER, WINE, COCKTAIL, SPIRIT, or OTHER
+- "subType" (string, optional): Style
+- "brewery" (string, optional): Producer
+- "reason" (string): Why it's similar and why they'd like it
+- "similarity" (number, 0-1): How similar to the source drink
+
+Sort by similarity descending. Do not include text before or after the JSON.`
+
+export function buildDrinkHistoryString(drinks: { name: string; type: string; subType?: string | null; brewery?: string | null; avgRating: number | null; ratingCount: number; wouldReorder: boolean }[]): string {
+ return drinks
+ .filter(d => d.avgRating !== null)
+ .sort((a, b) => (b.avgRating ?? 0) - (a.avgRating ?? 0))
+ .map(d => {
+ const parts = [`${d.name} (${d.type})`]
+ if (d.subType) parts.push(`Style: ${d.subType}`)
+ if (d.brewery) parts.push(`From: ${d.brewery}`)
+ parts.push(`Rating: ${d.avgRating}/5 (${d.ratingCount} rating${d.ratingCount !== 1 ? 's' : ''})`)
+ if (d.wouldReorder) parts.push('Would reorder: Yes')
+ return `- ${parts.join(' | ')}`
+ })
+ .join('\n') || 'No rated drinks yet.'
+}
diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts
index c730ae0..0bf43c1 100644
--- a/src/lib/ai/types.ts
+++ b/src/lib/ai/types.ts
@@ -42,6 +42,8 @@ export interface DrinkSearchResult {
export interface AIProvider {
name: string
+ sendTextRequest(systemPrompt: string, userMessage: string): Promise
+ sendVisionRequest(systemPrompt: string, imageBase64: string, mimeType: string): Promise
extractMenuItems(imageBase64: string, mimeType: string): Promise
recommendDrinks(
extractedItems: ExtractedMenuItem[],
diff --git a/src/lib/backup.ts b/src/lib/backup.ts
index 7b61a68..542dfd2 100644
--- a/src/lib/backup.ts
+++ b/src/lib/backup.ts
@@ -6,6 +6,7 @@ import type {
WishlistItem,
UserPreference,
SharedList,
+ BarItem,
} from "@prisma/client"
import crypto from "crypto"
@@ -39,6 +40,8 @@ const CSV_HEADERS = [
"minAbv",
"maxAbv",
"defaultProvider",
+ "category",
+ "quantity",
"createdAt",
"updatedAt",
]
@@ -87,6 +90,16 @@ export interface ParsedWishlistItem {
updatedAt?: Date
}
+export interface ParsedBarItem {
+ _originalId: string
+ name: string
+ category: string
+ quantity: string
+ notes?: string
+ createdAt?: Date
+ updatedAt?: Date
+}
+
export interface ParsedPreferences {
preferredStyles: string[]
avoidedStyles: string[]
@@ -112,6 +125,7 @@ export interface ParsedBackupData {
wishlistItems: ParsedWishlistItem[]
preferences: ParsedPreferences | null
sharedLists: ParsedSharedList[]
+ barItems: ParsedBarItem[]
}
export interface RestoreSummary {
@@ -120,6 +134,7 @@ export interface RestoreSummary {
wishlist: { created: number; updated: number; skipped: number }
preferences: { restored: boolean }
sharedLists: { created: number; updated: number; skipped: number }
+ barItems: { created: number; updated: number; skipped: number }
}
// ─── Export ─────────────────────────────────────────────────────
@@ -129,7 +144,8 @@ export function generateBackupCsv(
ratings: (Rating & { drink: { name: string } })[],
wishlistItems: WishlistItem[],
preferences: UserPreference | null,
- sharedLists: SharedList[]
+ sharedLists: SharedList[],
+ barItems: BarItem[] = []
): string {
const rows: Record[] = []
@@ -212,6 +228,20 @@ export function generateBackupCsv(
})
}
+ // Bar items
+ for (const b of barItems) {
+ rows.push({
+ _type: "bar_item",
+ _originalId: b.id,
+ name: b.name,
+ category: b.category,
+ quantity: b.quantity,
+ notes: b.notes ?? "",
+ createdAt: b.createdAt.toISOString(),
+ updatedAt: b.updatedAt.toISOString(),
+ })
+ }
+
return objectsToCsv(CSV_HEADERS, rows)
}
@@ -243,6 +273,7 @@ export function parseBackupRows(
wishlistItems: [],
preferences: null,
sharedLists: [],
+ barItems: [],
}
for (const row of rows) {
@@ -341,6 +372,18 @@ export function parseBackupRows(
})
break
+ case "bar_item":
+ data.barItems.push({
+ _originalId: row._originalId ?? "",
+ name: row.name ?? "",
+ category: row.category ?? "SPIRITS",
+ quantity: row.quantity ?? "FULL",
+ notes: row.notes || undefined,
+ createdAt: parseOptionalDate(row.createdAt ?? ""),
+ updatedAt: parseOptionalDate(row.updatedAt ?? ""),
+ })
+ break
+
// Skip unknown row types
}
}
@@ -390,6 +433,29 @@ export function validateBackupData(
if (!s.title) errors.push(`Shared list row ${i + 1}: title is required`)
}
+ const VALID_BAR_CATEGORIES = [
+ "SPIRITS",
+ "LIQUEURS",
+ "MIXERS",
+ "BITTERS",
+ "GARNISHES",
+ "TOOLS",
+ ]
+ const VALID_BAR_QUANTITIES = ["FULL", "HALF", "LOW", "EMPTY"]
+
+ for (let i = 0; i < data.barItems.length; i++) {
+ const b = data.barItems[i]
+ if (!b.name) errors.push(`Bar item row ${i + 1}: name is required`)
+ if (!VALID_BAR_CATEGORIES.includes(b.category))
+ errors.push(
+ `Bar item row ${i + 1}: invalid category "${b.category}". Expected one of: ${VALID_BAR_CATEGORIES.join(", ")}`
+ )
+ if (!VALID_BAR_QUANTITIES.includes(b.quantity))
+ errors.push(
+ `Bar item row ${i + 1}: invalid quantity "${b.quantity}". Expected one of: ${VALID_BAR_QUANTITIES.join(", ")}`
+ )
+ }
+
return { valid: errors.length === 0, errors }
}
@@ -397,6 +463,8 @@ export function validateBackupData(
type RestoreMode = "merge-skip" | "merge-update" | "replace"
type DrinkType = "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
+type BarItemCategory = "SPIRITS" | "LIQUEURS" | "MIXERS" | "BITTERS" | "GARNISHES" | "TOOLS"
+type BarItemQuantity = "FULL" | "HALF" | "LOW" | "EMPTY"
export async function executeRestore(
userId: string,
@@ -411,6 +479,7 @@ export async function executeRestore(
wishlist: { created: 0, updated: 0, skipped: 0 },
preferences: { restored: false },
sharedLists: { created: 0, updated: 0, skipped: 0 },
+ barItems: { created: 0, updated: 0, skipped: 0 },
}
// STEP 0: If "replace", delete everything first
@@ -420,6 +489,7 @@ export async function executeRestore(
await tx.drink.deleteMany({ where: { userId } })
await tx.wishlistItem.deleteMany({ where: { userId } })
await tx.userPreference.deleteMany({ where: { userId } })
+ await tx.barItem.deleteMany({ where: { userId } })
}
// STEP 1: Preferences
@@ -723,6 +793,55 @@ export async function executeRestore(
}
}
+ // STEP 6: Bar items
+ for (const item of data.barItems) {
+ if (mode === "replace") {
+ await tx.barItem.create({
+ data: {
+ userId,
+ name: item.name,
+ category: item.category as BarItemCategory,
+ quantity: item.quantity as BarItemQuantity,
+ notes: item.notes ?? null,
+ },
+ })
+ summary.barItems.created++
+ } else {
+ const existing = await tx.barItem.findFirst({
+ where: {
+ userId,
+ name: item.name,
+ category: item.category as BarItemCategory,
+ },
+ })
+ if (existing) {
+ if (mode === "merge-update") {
+ await tx.barItem.update({
+ where: { id: existing.id },
+ data: {
+ quantity: item.quantity as BarItemQuantity,
+ notes: item.notes ?? null,
+ },
+ })
+ summary.barItems.updated++
+ } else {
+ summary.barItems.skipped++
+ }
+ } else {
+ await tx.barItem.create({
+ data: {
+ userId,
+ name: item.name,
+ category: item.category as BarItemCategory,
+ quantity: item.quantity as BarItemQuantity,
+ notes: item.notes ?? null,
+ },
+ })
+ summary.barItems.created++
+ }
+ }
+ }
+
return summary
},
{ timeout: 60000 }
diff --git a/src/lib/validators.ts b/src/lib/validators.ts
index 8a7bca2..452f952 100644
--- a/src/lib/validators.ts
+++ b/src/lib/validators.ts
@@ -71,3 +71,30 @@ export type UserPreferenceInput = z.infer
export type WishlistCreate = z.infer
export type SharedListCreate = z.infer
export type SharedListUpdate = z.infer
+
+export const barItemCreateSchema = z.object({
+ name: z.string().min(1, "Name is required").max(200),
+ category: z.enum(["SPIRITS", "LIQUEURS", "MIXERS", "BITTERS", "GARNISHES", "TOOLS"]),
+ quantity: z.enum(["FULL", "HALF", "LOW", "EMPTY"]).default("FULL"),
+ notes: z.string().max(2000).optional(),
+})
+
+export const barItemUpdateSchema = barItemCreateSchema.partial()
+
+export type BarItemCreate = z.infer
+export type BarItemUpdate = z.infer
+
+export const recipeCreateSchema = z.object({
+ title: z.string().min(1).max(200),
+ ingredients: z.array(z.object({
+ name: z.string(),
+ amount: z.string(),
+ available: z.boolean(),
+ })),
+ steps: z.array(z.string()),
+ garnish: z.string().max(200).optional().nullable(),
+ glassware: z.string().max(200).optional().nullable(),
+ sourceDrinkId: z.string().optional().nullable(),
+ notes: z.string().max(2000).optional().nullable(),
+})
+export type RecipeCreate = z.infer