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:
@@ -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<string, string[]> = {}
|
||||
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.'
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface DrinkSearchResult {
|
||||
|
||||
export interface AIProvider {
|
||||
name: string
|
||||
sendTextRequest(systemPrompt: string, userMessage: string): Promise<string>
|
||||
sendVisionRequest(systemPrompt: string, imageBase64: string, mimeType: string): Promise<string>
|
||||
extractMenuItems(imageBase64: string, mimeType: string): Promise<MenuExtractionResult>
|
||||
recommendDrinks(
|
||||
extractedItems: ExtractedMenuItem[],
|
||||
|
||||
@@ -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<string, string>[] = []
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -71,3 +71,30 @@ export type UserPreferenceInput = z.infer<typeof userPreferenceSchema>
|
||||
export type WishlistCreate = z.infer<typeof wishlistCreateSchema>
|
||||
export type SharedListCreate = z.infer<typeof sharedListCreateSchema>
|
||||
export type SharedListUpdate = z.infer<typeof sharedListUpdateSchema>
|
||||
|
||||
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<typeof barItemCreateSchema>
|
||||
export type BarItemUpdate = z.infer<typeof barItemUpdateSchema>
|
||||
|
||||
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<typeof recipeCreateSchema>
|
||||
|
||||
Reference in New Issue
Block a user