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>
This commit is contained in:
JP Scott
2026-03-04 22:26:17 -07:00
parent 2ac2c4b2d4
commit dc1ad4d0c0
36 changed files with 1892 additions and 144 deletions

View File

@@ -183,7 +183,14 @@ Create a recipe for the requested cocktail. Return a valid JSON object:
- "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.`
Mark ingredients available:true if a matching item exists in bar inventory.
When checking availability, match flexibly:
- Ignore brand names — "Angostura Aromatic Bitters" matches "Angostura bitters" or just "bitters"
- Match the core ingredient identity — "simple syrup" matches "Simple Syrup", "bourbon" matches "Maker's Mark Bourbon"
- If the bar inventory lists a specific brand/product that IS the ingredient type, mark it available
- Be generous — if the user clearly has something that serves the same purpose, mark it available
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.
@@ -201,7 +208,14 @@ Return a valid JSON array of objects:
- "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.`
Sort by missingCount ascending. Return up to 10.
When checking availability, match flexibly:
- Ignore brand names — "Angostura Aromatic Bitters" matches "Angostura bitters" or just "bitters"
- Match the core ingredient identity — "simple syrup" matches "Simple Syrup", "bourbon" matches "Maker's Mark Bourbon"
- If the bar inventory lists a specific brand/product that IS the ingredient type, mark it available
- Be generous — if the user clearly has something that serves the same purpose, mark it available
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[]> = {}

View File

@@ -0,0 +1,68 @@
/**
* Post-process AI-generated recipe ingredients to fix incorrect
* available:false flags using fuzzy string matching against bar inventory.
*/
interface Ingredient {
name: string
amount: string
available: boolean
}
interface BarItemForMatching {
name: string
}
// Common words to ignore during matching
const STOP_WORDS = new Set([
"of", "or", "and", "the", "a", "an", "to", "for", "in", "on",
"fresh", "large", "small", "whole", "crushed", "muddled",
"oz", "ml", "cl", "dash", "dashes", "splash", "tsp", "tbsp",
"cup", "part", "parts", "slice", "slices", "piece", "pieces",
"cube", "cubes", "drop", "drops", "sprig", "sprigs", "leaf", "leaves",
])
function getSignificantWords(text: string): string[] {
return text
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length > 2 && !STOP_WORDS.has(w))
}
export function fuzzyMatchIngredients(
ingredients: Ingredient[],
barItems: BarItemForMatching[]
): Ingredient[] {
if (barItems.length === 0) return ingredients
const barItemNames = barItems.map((item) => item.name.toLowerCase())
return ingredients.map((ingredient) => {
if (ingredient.available) return ingredient
const ingName = ingredient.name.toLowerCase()
const matched = barItemNames.some((barName) => {
// Direct substring match in either direction
if (barName.includes(ingName)) return true
if (ingName.includes(barName)) return true
// Word-level: all significant words of the ingredient appear
// somewhere across bar item names
const ingWords = getSignificantWords(ingName)
if (ingWords.length > 0) {
return ingWords.every((word) =>
barItemNames.some((bn) => bn.includes(word))
)
}
return false
})
return matched ? { ...ingredient, available: true } : ingredient
})
}
export function recalculateMissingCount(ingredients: Ingredient[]): number {
return ingredients.filter((i) => !i.available).length
}

View File

@@ -31,9 +31,8 @@ export async function uploadImage(
})
)
const useSSL = process.env.MINIO_USE_SSL === "true"
const protocol = useSSL ? "https" : "http"
return `${protocol}://${process.env.MINIO_ENDPOINT}:${process.env.MINIO_PORT}/${BUCKET}/${key}`
// Return relative URL through Next.js proxy — works from any device
return `/minio-images/${key}`
}
export async function getImage(key: string) {
@@ -56,7 +55,6 @@ export async function deleteImage(key: string) {
}
export function getImageUrl(key: string): string {
const useSSL = process.env.MINIO_USE_SSL === "true"
const protocol = useSSL ? "https" : "http"
return `${protocol}://${process.env.MINIO_ENDPOINT}:${process.env.MINIO_PORT}/${BUCKET}/${key}`
// Return relative URL through Next.js proxy — works from any device
return `/minio-images/${key}`
}

View File

@@ -77,6 +77,8 @@ export const barItemCreateSchema = z.object({
category: z.enum(["SPIRITS", "LIQUEURS", "MIXERS", "BITTERS", "GARNISHES", "TOOLS"]),
quantity: z.enum(["FULL", "HALF", "LOW", "EMPTY"]).default("FULL"),
notes: z.string().max(2000).optional(),
barcode: z.string().max(50).optional(),
imageUrl: z.string().url().optional().or(z.literal("")),
})
export const barItemUpdateSchema = barItemCreateSchema.partial()