From dc1ad4d0c03587f4f4923a985f855ecdd4a538a8 Mon Sep 17 00:00:00 2001 From: JP Scott Date: Wed, 4 Mar 2026 22:26:17 -0700 Subject: [PATCH] Add recipes, images, AI photo ID, barcode scanning & ingredient matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 2 + docker-compose.yml | 1 + next.config.mjs | 12 + package-lock.json | 7 + package.json | 3 +- prisma/schema.prisma | 3 + src/app/(app)/bar/page.tsx | 64 ++- src/app/(app)/bartender/page.tsx | 5 +- src/app/(app)/drinks/[id]/page.tsx | 79 +++- src/app/(app)/recipes/page.tsx | 21 + src/app/api/ai/identify/route.ts | 116 +++++ src/app/api/bar/barcode-lookup/route.ts | 177 ++++++++ src/app/api/bartender/recreate/route.ts | 7 + src/app/api/bartender/suggest/route.ts | 16 + src/app/api/recipes/route.ts | 40 +- src/components/bar/bar-item-card.tsx | 9 + src/components/bar/bar-item-form.tsx | 22 +- src/components/bar/barcode-scan-dialog.tsx | 310 ++++++++++++++ src/components/bar/barcode-scanner.tsx | 150 +++++++ src/components/bartender/recreate-tab.tsx | 43 +- src/components/drinks/drink-card.tsx | 22 +- src/components/drinks/drink-detail-image.tsx | 45 ++ src/components/drinks/drink-form.tsx | 420 +++++++++++++++---- src/components/drinks/drink-recipes-list.tsx | 22 + src/components/layout/bottom-nav.tsx | 2 +- src/components/layout/history-section.tsx | 252 +++++++++++ src/components/layout/more-menu.tsx | 6 +- src/components/layout/sidebar.tsx | 7 +- src/hooks/use-bar.ts | 2 + src/hooks/use-barcode-lookup.ts | 33 ++ src/hooks/use-identify.ts | 39 ++ src/lib/ai/prompts.ts | 18 +- src/lib/ingredient-matcher.ts | 68 +++ src/lib/s3.ts | 10 +- src/lib/validators.ts | 2 + src/middleware.ts | 1 + 36 files changed, 1892 insertions(+), 144 deletions(-) create mode 100644 src/app/(app)/recipes/page.tsx create mode 100644 src/app/api/ai/identify/route.ts create mode 100644 src/app/api/bar/barcode-lookup/route.ts create mode 100644 src/components/bar/barcode-scan-dialog.tsx create mode 100644 src/components/bar/barcode-scanner.tsx create mode 100644 src/components/drinks/drink-detail-image.tsx create mode 100644 src/components/drinks/drink-recipes-list.tsx create mode 100644 src/components/layout/history-section.tsx create mode 100644 src/hooks/use-barcode-lookup.ts create mode 100644 src/hooks/use-identify.ts create mode 100644 src/lib/ingredient-matcher.ts diff --git a/.gitignore b/.gitignore index 3b02042..d1caaf8 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +certificates \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 09d48c0..6662ded 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,6 +64,7 @@ services: environment: DATABASE_URL: "postgresql://${POSTGRES_USER:-drinktracker}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-drinktracker}" MINIO_ENDPOINT: "minio" + WATCHPACK_POLLING: "true" depends_on: db: condition: service_healthy diff --git a/next.config.mjs b/next.config.mjs index 842fe14..1c807cb 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -16,6 +16,18 @@ const nextConfig = { }, ], }, + async rewrites() { + // Proxy image requests to MinIO so URLs work from any device + const minioHost = process.env.MINIO_ENDPOINT || "localhost"; + const minioPort = process.env.MINIO_PORT || "9000"; + const minioBucket = process.env.MINIO_BUCKET || "drink-images"; + return [ + { + source: "/minio-images/:path*", + destination: `http://${minioHost}:${minioPort}/${minioBucket}/:path*`, + }, + ]; + }, async headers() { return [ { diff --git a/package-lock.json b/package-lock.json index b62ceed..822c276 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "html5-qrcode": "^2.3.8", "lucide-react": "^0.575.0", "next": "14.2.35", "next-auth": "^5.0.0-beta.25", @@ -5359,6 +5360,12 @@ "node": ">= 0.4" } }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", + "license": "Apache-2.0" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index 188dddc..be4a808 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --experimental-https", "build": "next build", "start": "next start", "lint": "next lint" @@ -18,6 +18,7 @@ "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "html5-qrcode": "^2.3.8", "lucide-react": "^0.575.0", "next": "14.2.35", "next-auth": "^5.0.0-beta.25", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index de3c8b3..51f11e5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -279,6 +279,8 @@ model BarItem { category BarItemCategory quantity BarItemQuantity @default(FULL) notes String? @db.Text + barcode String? + imageUrl String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -286,6 +288,7 @@ model BarItem { @@index([userId]) @@index([userId, category]) + @@index([userId, barcode]) } // ─── Recipes ──────────────────────────────────────────────────── diff --git a/src/app/(app)/bar/page.tsx b/src/app/(app)/bar/page.tsx index 41fa168..de9b452 100644 --- a/src/app/(app)/bar/page.tsx +++ b/src/app/(app)/bar/page.tsx @@ -20,7 +20,9 @@ import { useDeleteBarItem, } from "@/hooks/use-bar" import type { BarItem } from "@/hooks/use-bar" -import { Plus, Wine } from "lucide-react" +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() { @@ -65,17 +67,40 @@ const CATEGORY_ORDER = [ function BarContent() { const [addDialogOpen, setAddDialogOpen] = useState(false) + const [scanDialogOpen, setScanDialogOpen] = useState(false) const [editingItem, setEditingItem] = useState(null) + const [scannedData, setScannedData] = useState | null>(null) const { data, isLoading, error } = useBarItems() const createBarItem = useCreateBarItem() const updateBarItem = useUpdateBarItem() const deleteBarItem = useDeleteBarItem() + function handleScanResult(result: BarcodeLookupResult) { + const initial: Partial & { 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) }, }) } @@ -124,10 +149,16 @@ function BarContent() { : "Your bar inventory"}

- +
+ + +
{isLoading ? ( @@ -180,16 +211,33 @@ function BarContent() { )} + {/* Barcode Scan Dialog */} + + {/* Add Item Dialog */} - + { + setAddDialogOpen(open) + if (!open) setScannedData(null) + }} + > Add Bar Item - Add a spirit, mixer, or other item to your bar inventory. + {scannedData + ? "Review the scanned product info and make any changes." + : "Add a spirit, mixer, or other item to your bar inventory."} ("recreate") return ( @@ -79,7 +82,7 @@ function BartenderContent() { {/* Tab content */} - {activeTab === "recreate" && } + {activeTab === "recreate" && } {activeTab === "suggest" && } {activeTab === "saved" && } diff --git a/src/app/(app)/drinks/[id]/page.tsx b/src/app/(app)/drinks/[id]/page.tsx index 152211c..97cf889 100644 --- a/src/app/(app)/drinks/[id]/page.tsx +++ b/src/app/(app)/drinks/[id]/page.tsx @@ -6,11 +6,14 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" -import { Star, MapPin, Percent, Calendar, ArrowLeft } from "lucide-react" +import { Star, MapPin, Percent, Calendar, ArrowLeft, GlassWater, BookOpen } from "lucide-react" import Link from "next/link" import { cn } from "@/lib/utils" import { DrinkDetailActions } from "@/components/drinks/drink-detail-actions" import { AddToWishlistButton } from "@/components/drinks/add-to-wishlist-button" +import { DrinkRecipesList } from "@/components/drinks/drink-recipes-list" +import { DrinkDetailImage } from "@/components/drinks/drink-detail-image" +import { fuzzyMatchIngredients } from "@/lib/ingredient-matcher" const TYPE_COLORS: Record = { BEER: "bg-amber-500/15 text-amber-700 border-amber-500/25", @@ -44,6 +47,9 @@ export default async function DrinkDetailPage({ ratings: { orderBy: { createdAt: "desc" }, }, + recipes: { + orderBy: { createdAt: "desc" }, + }, }, }) @@ -55,6 +61,24 @@ export default async function DrinkDetailPage({ notFound() } + // Fetch bar items to re-check recipe ingredient availability + const barItems = await prisma.barItem.findMany({ + where: { + userId: session.user.id, + quantity: { not: "EMPTY" }, + }, + select: { name: true }, + }) + + // Re-process recipe ingredients against current bar + const processedRecipes = drink.recipes.map((recipe) => { + if (barItems.length > 0 && Array.isArray(recipe.ingredients)) { + const ingredients = recipe.ingredients as { name: string; amount: string; available: boolean }[] + return { ...recipe, ingredients: fuzzyMatchIngredients(ingredients, barItems) } + } + return recipe + }) + const scores = drink.ratings.map((r) => r.score) const avgRating = scores.length > 0 @@ -75,13 +99,7 @@ export default async function DrinkDetailPage({ {/* Drink Image */} - {drink.imageUrl && ( - {drink.name} - )} + {/* Main Info Card */} @@ -179,13 +197,19 @@ export default async function DrinkDetailPage({ -
+
+ + + + {/* Recipes */} + + +
+ + + Recipes + +
+
+ + {processedRecipes.length > 0 ? ( + ({ + id: r.id, + title: r.title, + ingredients: r.ingredients as { name: string; amount: string; available: boolean }[], + steps: r.steps as string[], + garnish: r.garnish, + glassware: r.glassware, + notes: r.notes, + }))} + /> + ) : ( +
+

No saved recipes for this drink.

+ + + +
+ )} +
+
+ {/* Rating History */} diff --git a/src/app/(app)/recipes/page.tsx b/src/app/(app)/recipes/page.tsx new file mode 100644 index 0000000..3ad0df9 --- /dev/null +++ b/src/app/(app)/recipes/page.tsx @@ -0,0 +1,21 @@ +"use client" + +import { Header } from "@/components/layout/header" +import { SavedRecipesTab } from "@/components/bartender/saved-recipes-tab" + +export default function RecipesPage() { + return ( +
+
+
+
+

Saved Recipes

+

+ Your collection of saved cocktail recipes. +

+
+ +
+
+ ) +} diff --git a/src/app/api/ai/identify/route.ts b/src/app/api/ai/identify/route.ts new file mode 100644 index 0000000..a063cc0 --- /dev/null +++ b/src/app/api/ai/identify/route.ts @@ -0,0 +1,116 @@ +import { NextResponse } from "next/server" +import { auth } from "@/lib/auth" +import { prisma } from "@/lib/prisma" +import { decrypt } from "@/lib/encryption" +import { createProvider } from "@/lib/ai/provider-factory" +import { rateLimit } from "@/lib/rate-limit" +import { z } from "zod" + +const identifySchema = z.object({ + imageBase64: z.string().min(1), + mimeType: z.string().regex(/^image\/(jpeg|png|webp|heic)$/), + context: z.enum(["drink", "bar"]), +}) + +// Map AI drink types to bar item categories +function mapTypeToBarCategory(type: string, subType?: string, name?: string): string { + const t = type.toUpperCase() + const sub = (subType || "").toLowerCase() + const n = (name || "").toLowerCase() + const allText = `${sub} ${n}` + + // Check name + subType for specific categories first + if (allText.includes("bitter")) return "BITTERS" + + if (allText.includes("liqueur") || allText.includes("amaro") || allText.includes("vermouth") || + allText.includes("triple sec") || allText.includes("curaçao") || allText.includes("curacao") || + allText.includes("schnapps") || allText.includes("aperitif") || allText.includes("digestif") || + allText.includes("cordial") || allText.includes("crème de")) { + return "LIQUEURS" + } + + if (allText.includes("mixer") || allText.includes("soda") || allText.includes("tonic") || + allText.includes("juice") || allText.includes("syrup") || allText.includes("cola") || + allText.includes("ginger") || allText.includes("energy") || allText.includes("water") || + allText.includes("lemonade") || allText.includes("grenadine") || allText.includes("club")) { + return "MIXERS" + } + + if (allText.includes("garnish") || allText.includes("olive") || allText.includes("cherry") || + allText.includes("mint sprig")) { + return "GARNISHES" + } + + // Map by drink type + if (t === "SPIRIT") return "SPIRITS" + if (t === "COCKTAIL") return "SPIRITS" + if (t === "BEER" || t === "WINE") return "SPIRITS" + if (t === "OTHER") return "MIXERS" // energy drinks, non-alcoholic, etc. + + return "SPIRITS" +} + +export async function POST(request: Request) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { success: withinLimit } = rateLimit(`ai-identify:${session.user.id}`, 10, 60000) + if (!withinLimit) { + return NextResponse.json( + { error: "Too many requests. Please wait a moment." }, + { status: 429 } + ) + } + + try { + const body = await request.json() + const parsed = identifySchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid request" }, { status: 400 }) + } + + const { imageBase64, mimeType, context } = parsed.data + + const apiKeyRecord = await prisma.userApiKey.findFirst({ + where: { userId: session.user.id, isActive: true }, + }) + + if (!apiKeyRecord) { + return NextResponse.json( + { error: "No AI provider configured. Add an API key in Settings." }, + { status: 400 } + ) + } + + const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv) + const provider = createProvider(apiKeyRecord.provider, apiKey) + + const result = await provider.extractLabel(imageBase64, mimeType) + + // Build response based on context + const response: Record = { + name: result.name, + type: result.type, + subType: result.subType, + brewery: result.brewery, + region: result.region, + abv: result.abv, + description: result.description, + } + + if (context === "bar") { + // Map to bar category + response.category = mapTypeToBarCategory(result.type, result.subType, result.name) + } + + return NextResponse.json(response) + } catch (error) { + console.error("AI identify error:", error) + return NextResponse.json( + { error: "Failed to identify product. Please try again." }, + { status: 500 } + ) + } +} diff --git a/src/app/api/bar/barcode-lookup/route.ts b/src/app/api/bar/barcode-lookup/route.ts new file mode 100644 index 0000000..33b38d2 --- /dev/null +++ b/src/app/api/bar/barcode-lookup/route.ts @@ -0,0 +1,177 @@ +import { NextResponse } from "next/server" +import { auth } from "@/lib/auth" +import { prisma } from "@/lib/prisma" +import { decrypt } from "@/lib/encryption" +import { createProvider } from "@/lib/ai/provider-factory" +import { rateLimit } from "@/lib/rate-limit" +import { z } from "zod" + +const barcodeLookupSchema = z.object({ + barcode: z.string().min(8).max(20).regex(/^\d+$/, "Invalid barcode format"), +}) + +function mapOffCategoryToBarCategory(tags: string[]): string { + const joined = (tags || []).join(",").toLowerCase() + if (/spirits|whisk|bourbon|vodka|rum|gin|tequila|brandy|cognac|mezcal|scotch/.test(joined)) return "SPIRITS" + if (/liqueur|amaretto|kahlua|baileys|triple.sec|schnapps|chartreuse|campari|aperol/.test(joined)) return "LIQUEURS" + if (/juice|soda|tonic|cola|syrup|water|mixer|ginger|lemon|lime|cranberry|club/.test(joined)) return "MIXERS" + if (/bitter/.test(joined)) return "BITTERS" + return "SPIRITS" +} + +function mapOffCategoryToDrinkType(tags: string[]): string | null { + const joined = (tags || []).join(",").toLowerCase() + if (/beer|ale|lager|stout|porter|pilsner|ipa|wheat.beer|craft.beer/.test(joined)) return "BEER" + if (/wine|champagne|prosecco|cava|merlot|cabernet|chardonnay|pinot|rosé|rose/.test(joined)) return "WINE" + if (/cocktail/.test(joined)) return "COCKTAIL" + if (/spirits|whisk|bourbon|vodka|rum|gin|tequila|brandy|cognac|mezcal|scotch/.test(joined)) return "SPIRIT" + return null +} + +async function lookupOpenFoodFacts(barcode: string) { + try { + const res = await fetch( + `https://world.openfoodfacts.org/api/v2/product/${barcode}.json`, + { signal: AbortSignal.timeout(8000) } + ) + if (!res.ok) return null + const data = await res.json() + if (data.status !== 1 || !data.product) return null + + const product = data.product + const name = product.product_name || product.product_name_en || null + if (!name) return null + + // Extract product image URL + const imageUrl = product.image_url || product.image_front_url || product.image_front_small_url || null + + // Extract ABV from alcohol_100g nutrient or nutriments + let abv: number | null = null + if (product.nutriments?.alcohol_100g) { + abv = parseFloat(product.nutriments.alcohol_100g) + if (isNaN(abv)) abv = null + } + + // Determine drink type from categories + const drinkType = mapOffCategoryToDrinkType(product.categories_tags || []) + + return { + name, + brand: product.brands || null, + category: mapOffCategoryToBarCategory(product.categories_tags || []), + imageUrl, + abv, + type: drinkType, + subType: null as string | null, + } + } catch { + return null + } +} + +async function lookupViaAI(barcode: string, userId: string) { + try { + const apiKeyRecord = await prisma.userApiKey.findFirst({ + where: { userId, isActive: true }, + }) + if (!apiKeyRecord) return null + + const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv) + const provider = createProvider(apiKeyRecord.provider, apiKey) + + const systemPrompt = `You are a product identification expert. Given a UPC/EAN barcode number, identify the product — especially alcoholic beverages, spirits, mixers, and bar supplies. + +Return ONLY a valid JSON object with these fields: +- "name" (string): The product name (e.g., "Maker's Mark Bourbon") +- "brand" (string or null): The brand name +- "category" (string): One of "SPIRITS", "LIQUEURS", "MIXERS", "BITTERS", "GARNISHES", or "TOOLS" + +If you cannot confidently identify the barcode, return: { "name": null } +Do not include any text before or after the JSON.` + + const response = await provider.sendTextRequest( + systemPrompt, + `Identify the product with UPC/EAN barcode: ${barcode}` + ) + + const match = response.match(/\{[\s\S]*\}/) + if (!match) return null + const parsed = JSON.parse(match[0]) + if (!parsed.name) return null + + return { + name: parsed.name as string, + brand: (parsed.brand as string) || null, + category: parsed.category || "SPIRITS", + } + } catch { + return null + } +} + +export async function POST(request: Request) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { success: withinLimit } = rateLimit(`barcode-lookup:${session.user.id}`, 10, 60000) + if (!withinLimit) { + return NextResponse.json( + { error: "Too many requests. Please wait a moment." }, + { status: 429 } + ) + } + + try { + const body = await request.json() + const parsed = barcodeLookupSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid barcode format" }, { status: 400 }) + } + + const { barcode } = parsed.data + + // Check if user already has this barcode in their bar + const existing = await prisma.barItem.findFirst({ + where: { userId: session.user.id, barcode }, + }) + if (existing) { + return NextResponse.json({ + barcode, + name: existing.name, + brand: null, + category: existing.category, + source: "existing", + existingId: existing.id, + }) + } + + // Try Open Food Facts first + const offResult = await lookupOpenFoodFacts(barcode) + if (offResult) { + return NextResponse.json({ ...offResult, barcode, source: "openfoodfacts" }) + } + + // AI fallback + const aiResult = await lookupViaAI(barcode, session.user.id) + if (aiResult) { + return NextResponse.json({ ...aiResult, barcode, source: "ai" }) + } + + // Not found + return NextResponse.json({ + barcode, + name: null, + brand: null, + category: null, + source: "not_found", + }) + } catch (error) { + console.error("Barcode lookup error:", error) + return NextResponse.json( + { error: "Failed to look up barcode" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/bartender/recreate/route.ts b/src/app/api/bartender/recreate/route.ts index b204936..27fd089 100644 --- a/src/app/api/bartender/recreate/route.ts +++ b/src/app/api/bartender/recreate/route.ts @@ -5,6 +5,7 @@ import { decrypt } from "@/lib/encryption" import { createProvider } from "@/lib/ai/provider-factory" import { rateLimit } from "@/lib/rate-limit" import { COCKTAIL_RECIPE_PROMPT, buildBarInventoryString } from "@/lib/ai/prompts" +import { fuzzyMatchIngredients, recalculateMissingCount } from "@/lib/ingredient-matcher" import { z } from "zod" const recreateSchema = z.object({ @@ -82,6 +83,12 @@ export async function POST(request: Request) { } } + // Post-process: fuzzy-match ingredients against bar inventory + if (recipe.ingredients && Array.isArray(recipe.ingredients) && barItems.length > 0) { + recipe.ingredients = fuzzyMatchIngredients(recipe.ingredients, barItems) + recipe.missingCount = recalculateMissingCount(recipe.ingredients) + } + return NextResponse.json(recipe) } catch (error) { console.error("Bartender recreate error:", error) diff --git a/src/app/api/bartender/suggest/route.ts b/src/app/api/bartender/suggest/route.ts index fe24c7a..df9d7b4 100644 --- a/src/app/api/bartender/suggest/route.ts +++ b/src/app/api/bartender/suggest/route.ts @@ -5,6 +5,7 @@ import { decrypt } from "@/lib/encryption" import { createProvider } from "@/lib/ai/provider-factory" import { rateLimit } from "@/lib/rate-limit" import { WHAT_CAN_I_MAKE_PROMPT, buildBarInventoryString } from "@/lib/ai/prompts" +import { fuzzyMatchIngredients, recalculateMissingCount } from "@/lib/ingredient-matcher" export async function POST() { const session = await auth() @@ -80,6 +81,21 @@ export async function POST() { suggestions = [] } + // Post-process: fuzzy-match ingredients against bar inventory and re-sort + if (barItems.length > 0) { + suggestions = suggestions.map((s: { ingredients?: { name: string; amount: string; available: boolean }[]; missingCount?: number }) => { + if (s.ingredients && Array.isArray(s.ingredients)) { + s.ingredients = fuzzyMatchIngredients(s.ingredients, barItems) + s.missingCount = recalculateMissingCount(s.ingredients) + } + return s + }) + // Re-sort by missingCount ascending + suggestions.sort((a: { missingCount?: number }, b: { missingCount?: number }) => + (a.missingCount ?? 99) - (b.missingCount ?? 99) + ) + } + return NextResponse.json({ suggestions }) } catch (error) { console.error("Bartender suggest error:", error) diff --git a/src/app/api/recipes/route.ts b/src/app/api/recipes/route.ts index 2a9ad11..22cd8b7 100644 --- a/src/app/api/recipes/route.ts +++ b/src/app/api/recipes/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server" import { auth } from "@/lib/auth" import { prisma } from "@/lib/prisma" import { recipeCreateSchema } from "@/lib/validators" +import { fuzzyMatchIngredients, recalculateMissingCount } from "@/lib/ingredient-matcher" import type { Prisma } from "@prisma/client" export async function GET() { @@ -11,17 +12,40 @@ export async function GET() { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } - const recipes = await prisma.recipe.findMany({ - where: { userId: session.user.id }, - orderBy: { createdAt: "desc" }, - include: { - sourceDrink: { - select: { name: true, type: true }, + const [recipes, barItems] = await Promise.all([ + prisma.recipe.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: "desc" }, + include: { + sourceDrink: { + select: { name: true, type: true }, + }, }, - }, + }), + prisma.barItem.findMany({ + where: { + userId: session.user.id, + quantity: { not: "EMPTY" }, + }, + select: { name: true }, + }), + ]) + + // Re-process ingredient availability against current bar inventory + const processedRecipes = recipes.map((recipe) => { + if (barItems.length > 0 && Array.isArray(recipe.ingredients)) { + const ingredients = recipe.ingredients as { name: string; amount: string; available: boolean }[] + const matched = fuzzyMatchIngredients(ingredients, barItems) + return { + ...recipe, + ingredients: matched, + missingCount: recalculateMissingCount(matched), + } + } + return recipe }) - return NextResponse.json({ recipes }) + return NextResponse.json({ recipes: processedRecipes }) } catch (error) { console.error("GET /api/recipes error:", error) return NextResponse.json( diff --git a/src/components/bar/bar-item-card.tsx b/src/components/bar/bar-item-card.tsx index d5ddc78..fe2ab73 100644 --- a/src/components/bar/bar-item-card.tsx +++ b/src/components/bar/bar-item-card.tsx @@ -48,6 +48,15 @@ interface BarItemCardProps { export function BarItemCard({ item, onEdit, onDelete }: BarItemCardProps) { return ( + {item.imageUrl && ( +
+ {item.name} +
+ )}

diff --git a/src/components/bar/bar-item-form.tsx b/src/components/bar/bar-item-form.tsx index e74638d..ae5593e 100644 --- a/src/components/bar/bar-item-form.tsx +++ b/src/components/bar/bar-item-form.tsx @@ -6,6 +6,8 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { Select, SelectOption } from "@/components/ui/select" +import { DrinkImageUpload } from "@/components/drinks/drink-image-upload" +import { Barcode } from "lucide-react" import type { BarItemCreate } from "@/lib/validators" const CATEGORIES = [ @@ -25,7 +27,7 @@ const QUANTITIES = [ ] interface BarItemFormProps { - initialData?: Partial + initialData?: Partial & { imageUrl?: string | null } onSubmit: (data: BarItemCreate) => void isSubmitting?: boolean submitLabel?: string @@ -41,8 +43,13 @@ export function BarItemForm({ const [category, setCategory] = useState(initialData?.category || "SPIRITS") const [quantity, setQuantity] = useState(initialData?.quantity || "FULL") const [notes, setNotes] = useState(initialData?.notes || "") + const [imageUrl, setImageUrl] = useState( + initialData?.imageUrl || null + ) const [errors, setErrors] = useState>({}) + const barcode = initialData?.barcode + function handleSubmit(e: React.FormEvent) { e.preventDefault() @@ -65,6 +72,8 @@ export function BarItemForm({ } if (notes.trim()) data.notes = notes.trim() + if (barcode) data.barcode = barcode + if (imageUrl) data.imageUrl = imageUrl onSubmit(data) } @@ -84,6 +93,12 @@ export function BarItemForm({ {errors.name && (

{errors.name}

)} + {barcode && ( +
+ + UPC: {barcode} +
+ )}

@@ -120,6 +135,11 @@ export function BarItemForm({
+
+ + +
+
diff --git a/src/components/bar/barcode-scan-dialog.tsx b/src/components/bar/barcode-scan-dialog.tsx new file mode 100644 index 0000000..5f30325 --- /dev/null +++ b/src/components/bar/barcode-scan-dialog.tsx @@ -0,0 +1,310 @@ +"use client" + +import { useState, useCallback, useRef, useEffect } from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { BarcodeScanner } from "./barcode-scanner" +import { CameraCapture } from "@/components/scan/camera-capture" +import { useBarcodeLookup } from "@/hooks/use-barcode-lookup" +import type { BarcodeLookupResult } from "@/hooks/use-barcode-lookup" +import { useIdentifyProduct } from "@/hooks/use-identify" +import { Loader2, AlertCircle, PackageCheck, CheckCircle2, ScanLine, Camera } from "lucide-react" + +interface BarcodeScanDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onResult: (result: BarcodeLookupResult) => void +} + +type ScanMode = "barcode" | "photo" +type Phase = "scanning" | "looking-up" | "found" | "already-exists" | "error" + +export function BarcodeScanDialog({ + open, + onOpenChange, + onResult, +}: BarcodeScanDialogProps) { + const [scanMode, setScanMode] = useState("barcode") + const [phase, setPhase] = useState("scanning") + const [scannedBarcode, setScannedBarcode] = useState(null) + const [existingName, setExistingName] = useState(null) + const [foundResult, setFoundResult] = useState(null) + const [errorMessage, setErrorMessage] = useState(null) + const lookup = useBarcodeLookup() + const identify = useIdentifyProduct() + + const lookupRef = useRef(lookup) + lookupRef.current = lookup + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + const t = setTimeout(() => { + setPhase("scanning") + setScanMode("barcode") + setScannedBarcode(null) + setExistingName(null) + setFoundResult(null) + setErrorMessage(null) + }, 300) + return () => clearTimeout(t) + } + }, [open]) + + function handleAddToBar() { + if (!foundResult) return + const result = foundResult + onOpenChange(false) + setTimeout(() => { + onResult(result) + }, 250) + } + + const handleScan = useCallback(async (barcode: string) => { + console.log("[scan-dialog] barcode detected:", barcode) + setScannedBarcode(barcode) + setPhase("looking-up") + + try { + const result = await lookupRef.current.mutateAsync(barcode) + console.log("[scan-dialog] lookup result:", result) + + if (result.source === "existing") { + setExistingName(result.name) + setPhase("already-exists") + return + } + + setFoundResult(result) + setPhase("found") + } catch (err) { + console.error("[scan-dialog] lookup error:", err) + setErrorMessage("Could not identify this barcode. You can try again or add the item manually.") + setPhase("error") + } + }, []) + + async function handlePhotoCapture(file: File) { + setPhase("looking-up") + + try { + // Convert file to base64 + const buffer = await file.arrayBuffer() + const base64 = btoa( + new Uint8Array(buffer).reduce((data, byte) => data + String.fromCharCode(byte), "") + ) + + const result = await identify.mutateAsync({ + imageBase64: base64, + mimeType: file.type || "image/jpeg", + context: "bar", + }) + + // Convert IdentifyResult to BarcodeLookupResult format + const lookupResult: BarcodeLookupResult = { + name: result.name, + brand: result.brewery || null, + category: result.category || null, + barcode: "", + source: "ai" as const, + } + + setFoundResult(lookupResult) + setPhase("found") + } catch (err) { + console.error("[scan-dialog] photo identify error:", err) + setErrorMessage("Could not identify this product from the photo. Try a clearer image or add the item manually.") + setPhase("error") + } + } + + function switchMode(mode: ScanMode) { + setScanMode(mode) + setPhase("scanning") + setErrorMessage(null) + } + + const dialogTitle = scanMode === "barcode" ? "Scan Barcode" : "Identify by Photo" + const dialogDesc = + phase === "scanning" + ? scanMode === "barcode" + ? "Point your camera at the barcode on a bottle or can." + : "Take a photo of the bottle or label." + : phase === "looking-up" + ? scanMode === "barcode" ? "Looking up the product..." : "Identifying the product..." + : phase === "found" + ? "Product identified!" + : phase === "already-exists" + ? "This item is already in your bar." + : "Something went wrong." + + return ( + + + + {dialogTitle} + {dialogDesc} + + + {/* Mode toggle — only show during scanning phase */} + {phase === "scanning" && ( +
+ + +
+ )} + + {/* Scanning phase */} + {phase === "scanning" && scanMode === "barcode" && ( + onOpenChange(false)} + /> + )} + + {phase === "scanning" && scanMode === "photo" && ( + onOpenChange(false)} + /> + )} + + {/* Looking up phase */} + {phase === "looking-up" && ( +
+ +
+

+ {scanMode === "barcode" ? "Looking up product..." : "AI is identifying the product..."} +

+ {scannedBarcode && ( +

+ Barcode: {scannedBarcode} +

+ )} +
+
+ )} + + {/* Found phase */} + {phase === "found" && foundResult && ( +
+ +
+

Product found!

+

+ {foundResult.brand && foundResult.name + ? `${foundResult.brand} ${foundResult.name}` + : foundResult.name || "Unknown product"} +

+ {foundResult.category && ( +

+ Category: {foundResult.category} +

+ )} + {scannedBarcode && ( +

+ UPC: {scannedBarcode} +

+ )} +
+
+ + +
+
+ )} + + {/* Already exists phase */} + {phase === "already-exists" && ( +
+ +
+

Already in your bar!

+

+ “{existingName}” is already in your inventory. +

+
+
+ + +
+
+ )} + + {/* Error phase */} + {phase === "error" && ( +
+ +
+

+ {scanMode === "barcode" ? "Lookup failed" : "Identification failed"} +

+

+ {errorMessage || "Something went wrong. Please try again."} +

+
+
+ + +
+
+ )} +
+
+ ) +} diff --git a/src/components/bar/barcode-scanner.tsx b/src/components/bar/barcode-scanner.tsx new file mode 100644 index 0000000..5e17e4c --- /dev/null +++ b/src/components/bar/barcode-scanner.tsx @@ -0,0 +1,150 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { Button } from "@/components/ui/button" +import { Camera, X, AlertCircle } from "lucide-react" + +interface BarcodeScannerProps { + onScan: (barcode: string) => void + onClose: () => void +} + +export function BarcodeScanner({ onScan, onClose }: BarcodeScannerProps) { + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const scannerRef = useRef(null) + const stoppedRef = useRef(false) + const onScanRef = useRef(onScan) + onScanRef.current = onScan + const containerRef = useRef("barcode-reader-" + Math.random().toString(36).slice(2)) + + useEffect(() => { + let mounted = true + stoppedRef.current = false + + async function startScanner() { + try { + // Dynamic import to avoid SSR issues + const { Html5Qrcode } = await import("html5-qrcode") + + if (!mounted) return + + const scanner = new Html5Qrcode(containerRef.current) + scannerRef.current = scanner + + await scanner.start( + { facingMode: "environment" }, + { + fps: 10, + qrbox: { width: 250, height: 100 }, + aspectRatio: 1.0, + }, + (decodedText) => { + // Prevent double-fire + if (stoppedRef.current) return + stoppedRef.current = true + + // Mark scanner as handled so cleanup doesn't double-stop + scannerRef.current = null + + // Stop camera then report result + scanner.stop().then(() => { + if (mounted) onScanRef.current(decodedText) + }).catch(() => { + if (mounted) onScanRef.current(decodedText) + }) + }, + () => { + // Per-frame decode failure — ignore + } + ) + + if (mounted) setLoading(false) + } catch (err) { + if (!mounted) return + setLoading(false) + + const isInsecure = typeof window !== "undefined" && window.location.protocol === "http:" && window.location.hostname !== "localhost" + + if (err instanceof Error) { + if (err.message.includes("Permission") || err.message.includes("NotAllowedError")) { + setError( + isInsecure + ? "Camera blocked — HTTPS is required. Access this page via https:// (port 3000) to use the scanner." + : "Camera permission denied. Please allow camera access and try again." + ) + } else if (err.message.includes("NotFoundError") || err.message.includes("Requested device not found")) { + setError("No camera found on this device.") + } else if (isInsecure) { + setError("Camera requires HTTPS. Access this page via https:// (port 3000) to use the scanner.") + } else { + setError("Could not start camera. Please try again.") + } + } else if (isInsecure) { + setError("Camera requires HTTPS. Access this page via https:// (port 3000) to use the scanner.") + } else { + setError("Could not start camera. Please try again.") + } + } + } + + startScanner() + + return () => { + mounted = false + // Only stop if the decode callback didn't already stop it + try { + const scanner = scannerRef.current as { stop?: () => Promise } | null + if (scanner?.stop) { + scanner.stop().catch(() => {}) + } + } catch { + // Scanner already stopped or disposed + } + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + return ( +
+ {/* Close button */} + + + {/* Scanner viewport */} +
+
+ + {loading && !error && ( +
+ +

Starting camera...

+
+ )} +
+ + {/* Instruction text */} + {!error && ( +

+ Point your camera at a barcode on a bottle or can +

+ )} + + {/* Error state */} + {error && ( +
+ +

{error}

+ +
+ )} +
+ ) +} diff --git a/src/components/bartender/recreate-tab.tsx b/src/components/bartender/recreate-tab.tsx index 76652b0..b7c9e94 100644 --- a/src/components/bartender/recreate-tab.tsx +++ b/src/components/bartender/recreate-tab.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useState, useEffect, useRef } from "react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Skeleton } from "@/components/ui/skeleton" @@ -10,26 +10,37 @@ import { Search, Loader2, Sparkles } from "lucide-react" import type { RecipeCardData } from "./recipe-card" import type { RecipeCreate } from "@/lib/validators" -export function RecreateTab() { - const [cocktailName, setCocktailName] = useState("") - const [recipe, setRecipe] = useState(null) +interface RecreateTabProps { + initialDrink?: string +} + +export function RecreateTab({ initialDrink }: RecreateTabProps) { + const [cocktailName, setCocktailName] = useState(initialDrink || "") + const [savedId, setSavedId] = useState(null) + const autoTriggered = useRef(false) const recreate = useRecreateRecipe() const saveRecipe = useSaveRecipe() + // Auto-trigger search when coming from drink detail page + useEffect(() => { + if (initialDrink && !autoTriggered.current) { + autoTriggered.current = true + recreate.mutate({ cocktailName: initialDrink }) + } + }, [initialDrink]) // eslint-disable-line react-hooks/exhaustive-deps + + // The recipe to display — comes from the mutation result + const recipe: RecipeCardData | null = recreate.data + ? { ...recreate.data, id: savedId || recreate.data.id } + : null + function handleSubmit(e: React.FormEvent) { e.preventDefault() const name = cocktailName.trim() if (!name) return - - recreate.mutate( - { cocktailName: name }, - { - onSuccess: (data) => { - setRecipe(data) - }, - } - ) + setSavedId(null) + recreate.mutate({ cocktailName: name }) } function handleSave(recipeData: RecipeCardData) { @@ -43,9 +54,7 @@ export function RecreateTab() { } saveRecipe.mutate(payload, { onSuccess: () => { - setRecipe((prev) => - prev ? { ...prev, id: "saved" } : prev - ) + setSavedId("saved") }, }) } @@ -99,7 +108,7 @@ export function RecreateTab() { recipe={recipe} onSave={handleSave} isSaving={saveRecipe.isPending} - saved={recipe.id === "saved" || saveRecipe.isSuccess} + saved={savedId === "saved" || saveRecipe.isSuccess} /> )}
diff --git a/src/components/drinks/drink-card.tsx b/src/components/drinks/drink-card.tsx index 35140ed..a1ea189 100644 --- a/src/components/drinks/drink-card.tsx +++ b/src/components/drinks/drink-card.tsx @@ -3,7 +3,7 @@ import Link from "next/link" import { Card, CardContent } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" -import { Star } from "lucide-react" +import { Star, GlassWater } from "lucide-react" import { cn } from "@/lib/utils" import type { DrinkListItem } from "@/hooks/use-drinks" @@ -84,11 +84,21 @@ export function DrinkCard({ drink }: DrinkCardProps) { )}
- {drink.abv !== null && drink.abv !== undefined && ( - - {drink.abv}% ABV - - )} +
+ e.stopPropagation()} + className="text-muted-foreground hover:text-primary transition-colors" + title="Recreate recipe" + > + + + {drink.abv !== null && drink.abv !== undefined && ( + + {drink.abv}% ABV + + )} +
diff --git a/src/components/drinks/drink-detail-image.tsx b/src/components/drinks/drink-detail-image.tsx new file mode 100644 index 0000000..81a86a7 --- /dev/null +++ b/src/components/drinks/drink-detail-image.tsx @@ -0,0 +1,45 @@ +"use client" + +import { useState } from "react" +import { DrinkImageUpload } from "./drink-image-upload" +import { Loader2 } from "lucide-react" + +interface DrinkDetailImageProps { + drinkId: string + currentImageUrl: string | null +} + +export function DrinkDetailImage({ drinkId, currentImageUrl }: DrinkDetailImageProps) { + const [imageUrl, setImageUrl] = useState(currentImageUrl) + const [saving, setSaving] = useState(false) + + async function handleImageChange(newUrl: string | null) { + setImageUrl(newUrl) + setSaving(true) + + try { + await fetch(`/api/drinks/${drinkId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ imageUrl: newUrl || undefined }), + }) + } catch (err) { + console.error("Failed to update drink image:", err) + // Revert on error + setImageUrl(currentImageUrl) + } finally { + setSaving(false) + } + } + + return ( +
+ {saving && ( +
+ +
+ )} + +
+ ) +} diff --git a/src/components/drinks/drink-form.tsx b/src/components/drinks/drink-form.tsx index 1214853..f8a1cda 100644 --- a/src/components/drinks/drink-form.tsx +++ b/src/components/drinks/drink-form.tsx @@ -1,12 +1,24 @@ "use client" -import { useState } from "react" +import { useState, useCallback, useRef } from "react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { Select, SelectOption } from "@/components/ui/select" import { DrinkImageUpload } from "@/components/drinks/drink-image-upload" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog" +import { BarcodeScanner } from "@/components/bar/barcode-scanner" +import { CameraCapture } from "@/components/scan/camera-capture" +import { useBarcodeLookup } from "@/hooks/use-barcode-lookup" +import { useIdentifyProduct } from "@/hooks/use-identify" +import { ScanLine, Camera, Loader2, CheckCircle2, AlertCircle } from "lucide-react" import type { DrinkCreate } from "@/lib/validators" const DRINK_TYPES = [ @@ -24,6 +36,8 @@ interface DrinkFormProps { submitLabel?: string } +type ScanPhase = "scanning" | "looking-up" | "found" | "error" + export function DrinkForm({ initialData, onSubmit, @@ -44,6 +58,122 @@ export function DrinkForm({ ) const [errors, setErrors] = useState>({}) + // Scan dialog state + const [scanDialogOpen, setScanDialogOpen] = useState(false) + const [scanType, setScanType] = useState<"barcode" | "photo">("barcode") + const [scanPhase, setScanPhase] = useState("scanning") + const [scanError, setScanError] = useState(null) + const [scanResult, setScanResult] = useState<{ + name: string + type?: string + subType?: string + brewery?: string + abv?: number + description?: string + imageUrl?: string + } | null>(null) + + const lookup = useBarcodeLookup() + const identify = useIdentifyProduct() + const lookupRef = useRef(lookup) + lookupRef.current = lookup + + function fillFormFromResult(result: { + name?: string + type?: string + subType?: string + brewery?: string + region?: string + abv?: number + description?: string + imageUrl?: string + }) { + if (result.name) setName(result.name) + if (result.type) setType(result.type as typeof type) + if (result.subType) setSubType(result.subType) + if (result.brewery) setBrewery(result.brewery) + if (result.region) setRegion(result.region) + if (result.abv) setAbv(result.abv.toString()) + if (result.description) setDescription(result.description) + if (result.imageUrl) setImageUrl(result.imageUrl) + } + + function openScanDialog(mode: "barcode" | "photo") { + setScanType(mode) + setScanPhase("scanning") + setScanError(null) + setScanResult(null) + setScanDialogOpen(true) + } + + function closeScanDialog() { + setScanDialogOpen(false) + setTimeout(() => { + setScanPhase("scanning") + setScanError(null) + setScanResult(null) + }, 300) + } + + const handleBarcodeScan = useCallback(async (barcode: string) => { + setScanPhase("looking-up") + try { + const result = await lookupRef.current.mutateAsync(barcode) + const displayName = result.brand && result.name + ? `${result.brand} ${result.name}` + : result.name || "Unknown product" + + const found: typeof scanResult & {} = { + name: displayName, + type: result.type || undefined, + subType: result.subType || undefined, + abv: result.abv || undefined, + imageUrl: result.imageUrl || undefined, + } + setScanResult(found) + setScanPhase("found") + } catch { + setScanError("Could not identify this barcode.") + setScanPhase("error") + } + }, []) + + async function handlePhotoCaptureForDrink(file: File) { + setScanPhase("looking-up") + try { + const buffer = await file.arrayBuffer() + const base64 = btoa( + new Uint8Array(buffer).reduce((data, byte) => data + String.fromCharCode(byte), "") + ) + + const result = await identify.mutateAsync({ + imageBase64: base64, + mimeType: file.type || "image/jpeg", + context: "drink", + }) + + setScanResult({ + name: result.name, + type: result.type, + subType: result.subType, + brewery: result.brewery, + abv: result.abv, + description: result.description, + }) + setScanPhase("found") + } catch { + setScanError("Could not identify this product from the photo.") + setScanPhase("error") + } + } + + function handleAcceptScanResult() { + if (scanResult) { + fillFormFromResult(scanResult) + } + closeScanDialog() + } + function handleSubmit(e: React.FormEvent) { e.preventDefault() @@ -80,106 +210,232 @@ export function DrinkForm({ } return ( -
-
- - setName(e.target.value)} - /> - {errors.name && ( -

{errors.name}

- )} -
- -
-
- - + + Scan Barcode + +
- + setSubType(e.target.value)} + id="drink-name" + placeholder="e.g., Two Hearted Ale" + value={name} + onChange={(e) => setName(e.target.value)} /> + {errors.name && ( +

{errors.name}

+ )} +
+ +
+
+ + +
+ +
+ + setSubType(e.target.value)} + /> +
+
+ +
+
+ + setBrewery(e.target.value)} + /> +
+ +
+ + setRegion(e.target.value)} + /> +
-
-
- + setBrewery(e.target.value)} + id="drink-abv" + type="number" + step="0.1" + min="0" + max="100" + placeholder="e.g., 7.0" + value={abv} + onChange={(e) => setAbv(e.target.value)} />
- - setRegion(e.target.value)} + +