Initial commit: DrinkTracker full-stack app

Next.js 14 drink collection tracker with AI-powered search,
menu scanning, ratings, wishlist, sharing, and CSV backup/restore.

Features:
- Auth (credentials + OAuth ready)
- Drink collection with ratings and reviews
- AI search via Claude/OpenAI with search history
- Menu photo scanning with AI extraction
- Wishlist / Try Later system
- Public sharing via slug URLs
- CSV backup and restore (merge/replace modes)
- Docker Compose for Postgres + MinIO + dev server

Security: docker-compose files use env var interpolation
instead of hardcoded secrets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JP Scott
2026-03-01 12:27:08 -07:00
commit 969bc9347a
115 changed files with 19397 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { Header } from "@/components/layout/header"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Beer, Wine, Star, Camera, TrendingUp, Clock } from "lucide-react"
import Link from "next/link"
export default async function DashboardPage() {
const session = await auth()
if (!session?.user?.id) return null
const [drinkCount, ratingCount, scanCount, recentRatings] = await Promise.all([
prisma.drink.count({ where: { userId: session.user.id } }),
prisma.rating.count({ where: { userId: session.user.id } }),
prisma.menuScan.count({ where: { userId: session.user.id } }),
prisma.rating.findMany({
where: { userId: session.user.id },
include: { drink: true },
orderBy: { createdAt: "desc" },
take: 5,
}),
])
const avgRating = ratingCount > 0
? await prisma.rating.aggregate({
where: { userId: session.user.id },
_avg: { score: true },
})
: null
return (
<div>
<Header title="Dashboard" />
<div className="p-4 md:p-8 space-y-6">
<div>
<h1 className="text-2xl font-bold">
Welcome back{session.user.name ? `, ${session.user.name.split(" ")[0]}` : ""}
</h1>
<p className="text-muted-foreground">
Here&apos;s your drinking diary at a glance
</p>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-2 gap-3">
<Link href="/scan">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardContent className="flex flex-col items-center gap-2 pt-6 pb-4">
<Camera className="h-8 w-8 text-primary" />
<span className="text-sm font-medium">Scan Menu</span>
</CardContent>
</Card>
</Link>
<Link href="/drinks?action=add">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardContent className="flex flex-col items-center gap-2 pt-6 pb-4">
<Beer className="h-8 w-8 text-primary" />
<span className="text-sm font-medium">Add Drink</span>
</CardContent>
</Card>
</Link>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Wine className="h-4 w-4" />
Drinks
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{drinkCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Star className="h-4 w-4" />
Ratings
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{ratingCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Avg Rating
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{avgRating?._avg?.score ? avgRating._avg.score.toFixed(1) : "—"}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Camera className="h-4 w-4" />
Scans
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{scanCount}</div>
</CardContent>
</Card>
</div>
{/* Recent Activity */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Recent Ratings
</CardTitle>
</CardHeader>
<CardContent>
{recentRatings.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No ratings yet. Start by adding a drink!</p>
<Link href="/drinks?action=add">
<Button className="mt-3" variant="outline">
Add Your First Drink
</Button>
</Link>
</div>
) : (
<div className="space-y-3">
{recentRatings.map((rating) => (
<Link
key={rating.id}
href={`/drinks/${rating.drinkId}`}
className="flex items-center justify-between py-2 hover:bg-accent/50 -mx-2 px-2 rounded-md transition-colors"
>
<div>
<p className="font-medium">{rating.drink.name}</p>
<p className="text-sm text-muted-foreground">
{rating.drink.brewery || rating.drink.subType || rating.drink.type}
</p>
</div>
<div className="flex items-center gap-1 text-primary">
{Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={`h-4 w-4 ${i < rating.score ? "fill-primary" : "fill-none opacity-30"}`}
/>
))}
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,273 @@
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { notFound, redirect } from "next/navigation"
import { Header } from "@/components/layout/header"
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 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"
const TYPE_COLORS: Record<string, string> = {
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<string, string> = {
BEER: "Beer",
WINE: "Wine",
COCKTAIL: "Cocktail",
SPIRIT: "Spirit",
OTHER: "Other",
}
export default async function DrinkDetailPage({
params,
}: {
params: { id: string }
}) {
const session = await auth()
if (!session?.user?.id) {
redirect("/login")
}
const drink = await prisma.drink.findUnique({
where: { id: params.id },
include: {
ratings: {
orderBy: { createdAt: "desc" },
},
},
})
if (!drink) {
notFound()
}
if (drink.userId !== session.user.id) {
notFound()
}
const scores = drink.ratings.map((r) => r.score)
const avgRating =
scores.length > 0
? scores.reduce((sum, s) => sum + s, 0) / scores.length
: null
return (
<div>
<Header title={drink.name} />
<div className="p-4 md:p-8 space-y-6 max-w-3xl mx-auto">
{/* Back link */}
<Link
href="/drinks"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to Collection
</Link>
{/* Main Info Card */}
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle className="text-2xl">{drink.name}</CardTitle>
<div className="flex items-center gap-2 flex-wrap">
<Badge
className={cn(
TYPE_COLORS[drink.type] || TYPE_COLORS.OTHER
)}
>
{TYPE_LABELS[drink.type] || drink.type}
</Badge>
{drink.subType && (
<Badge variant="outline">{drink.subType}</Badge>
)}
</div>
</div>
<DrinkDetailActions drinkId={drink.id} drinkName={drink.name} />
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Rating summary */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={cn(
"h-5 w-5",
avgRating && i < Math.round(avgRating)
? "fill-primary text-primary"
: "fill-none text-muted-foreground/30"
)}
/>
))}
</div>
<span className="text-sm text-muted-foreground">
{avgRating
? `${avgRating.toFixed(1)} avg from ${scores.length} rating${scores.length !== 1 ? "s" : ""}`
: "No ratings yet"}
</span>
</div>
<Separator />
{/* Details grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
{drink.brewery && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground font-medium min-w-[80px]">
Brewery
</span>
<span>{drink.brewery}</span>
</div>
)}
{drink.region && (
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-muted-foreground shrink-0" />
<span>{drink.region}</span>
</div>
)}
{drink.abv !== null && (
<div className="flex items-center gap-2">
<Percent className="h-4 w-4 text-muted-foreground shrink-0" />
<span>{drink.abv}% ABV</span>
</div>
)}
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
<span>
Added{" "}
{new Date(drink.createdAt).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
</div>
</div>
{drink.description && (
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-1">Description</h4>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{drink.description}
</p>
</div>
</>
)}
<Separator />
<div className="flex items-center gap-3">
<Link href={`/rate/${drink.id}`}>
<Button className="w-full sm:w-auto">
<Star className="h-4 w-4 mr-2" />
Rate This Drink
</Button>
</Link>
<AddToWishlistButton
name={drink.name}
type={drink.type as "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"}
subType={drink.subType}
brewery={drink.brewery}
abv={drink.abv}
description={drink.description}
source="collection"
size="default"
/>
</div>
</CardContent>
</Card>
{/* Rating History */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Rating History</CardTitle>
</CardHeader>
<CardContent>
{drink.ratings.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
<p>No ratings yet.</p>
<Link href={`/rate/${drink.id}`}>
<Button variant="outline" className="mt-3" size="sm">
Add Your First Rating
</Button>
</Link>
</div>
) : (
<div className="space-y-4">
{drink.ratings.map((rating) => (
<div
key={rating.id}
className="border rounded-lg p-4 space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={cn(
"h-4 w-4",
i < rating.score
? "fill-primary text-primary"
: "fill-none text-muted-foreground/30"
)}
/>
))}
<span className="text-sm font-medium ml-2">
{rating.score}/5
</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(rating.createdAt).toLocaleDateString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
)}
</span>
</div>
{rating.notes && (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{rating.notes}
</p>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{rating.wouldReorder && (
<Badge variant="secondary" className="text-[11px]">
Would reorder
</Badge>
)}
{rating.location && (
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{rating.location}
</span>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,284 @@
"use client"
import { Suspense, useState, useEffect, useCallback } from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { Header } from "@/components/layout/header"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog"
import { DrinkFilters } from "@/components/drinks/drink-filters"
import { DrinkCard } from "@/components/drinks/drink-card"
import { DrinkForm } from "@/components/drinks/drink-form"
import { AiDrinkSearch } from "@/components/drinks/ai-drink-search"
import { useDrinks, useCreateDrink } from "@/hooks/use-drinks"
import { Plus, Wine, Sparkles, PenLine } from "lucide-react"
import { ShareButton } from "@/components/sharing/share-button"
import { cn } from "@/lib/utils"
import type { DrinkCreate } from "@/lib/validators"
export default function DrinksPage() {
return (
<Suspense fallback={<DrinksLoading />}>
<DrinksContent />
</Suspense>
)
}
function DrinksLoading() {
return (
<div>
<Header title="My Drinks" />
<div className="p-4 md:p-8 space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 8 }, (_, i) => (
<Skeleton key={i} className="h-[160px] rounded-lg" />
))}
</div>
</div>
</div>
)
}
function DrinksContent() {
const router = useRouter()
const searchParams = useSearchParams()
const [search, setSearch] = useState("")
const [type, setType] = useState("ALL")
const [sort, setSort] = useState("recent")
const [page, setPage] = useState(1)
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [addMode, setAddMode] = useState<"ai" | "manual">("ai")
// Open add dialog if URL has ?action=add
useEffect(() => {
if (searchParams.get("action") === "add") {
setAddDialogOpen(true)
}
}, [searchParams])
// Debounce search
const [debouncedSearch, setDebouncedSearch] = useState("")
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search)
setPage(1)
}, 300)
return () => clearTimeout(timer)
}, [search])
const { data, isLoading, error } = useDrinks({
search: debouncedSearch,
type,
sort,
page,
limit: 20,
})
const createDrink = useCreateDrink()
const handleTypeChange = useCallback((value: string) => {
setType(value)
setPage(1)
}, [])
const handleSortChange = useCallback((value: string) => {
setSort(value)
setPage(1)
}, [])
function handleCreate(formData: DrinkCreate) {
createDrink.mutate(formData, {
onSuccess: (newDrink) => {
setAddDialogOpen(false)
router.push(`/drinks/${newDrink.id}`)
},
})
}
function handleCloseDialog(open: boolean) {
setAddDialogOpen(open)
// Clear the ?action=add param when closing
if (!open && searchParams.get("action") === "add") {
router.replace("/drinks")
}
}
return (
<div>
<Header title="My Drinks" />
<div className="p-4 md:p-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">My Collection</h1>
<p className="text-muted-foreground">
{data?.pagination.total !== undefined
? `${data.pagination.total} drink${data.pagination.total !== 1 ? "s" : ""} in your collection`
: "Your drink collection"}
</p>
</div>
<div className="flex gap-2">
<ShareButton />
<Button onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Drink
</Button>
</div>
</div>
<DrinkFilters
search={search}
type={type}
sort={sort}
onSearchChange={setSearch}
onTypeChange={handleTypeChange}
onSortChange={handleSortChange}
/>
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 8 }, (_, i) => (
<Skeleton key={i} className="h-[160px] rounded-lg" />
))}
</div>
) : error ? (
<div className="text-center py-12">
<p className="text-destructive">
Failed to load drinks. Please try again.
</p>
</div>
) : data?.drinks.length === 0 ? (
<div className="text-center py-16 space-y-4">
<Wine className="h-12 w-12 mx-auto text-muted-foreground/50" />
<div>
<h3 className="text-lg font-semibold">No drinks yet</h3>
<p className="text-muted-foreground mt-1">
{debouncedSearch || type !== "ALL"
? "No drinks match your filters. Try adjusting your search."
: "Start building your collection by adding your first drink."}
</p>
</div>
{!debouncedSearch && type === "ALL" && (
<Button onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Your First Drink
</Button>
)}
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{data?.drinks.map((drink) => (
<DrinkCard key={drink.id} drink={drink} />
))}
</div>
{/* Pagination */}
{data && data.pagination.totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-4">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
Previous
</Button>
<span className="text-sm text-muted-foreground px-2">
Page {page} of {data.pagination.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
setPage((p) =>
Math.min(data.pagination.totalPages, p + 1)
)
}
disabled={page >= data.pagination.totalPages}
>
Next
</Button>
</div>
)}
</>
)}
</div>
{/* Add Drink Dialog */}
<Dialog open={addDialogOpen} onOpenChange={handleCloseDialog}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[550px]">
<DialogHeader>
<DialogTitle>Add a New Drink</DialogTitle>
<DialogDescription>
Search with AI or fill in the details manually.
</DialogDescription>
</DialogHeader>
{/* Tab switcher */}
<div className="flex gap-1 p-1 bg-muted rounded-lg">
<button
onClick={() => setAddMode("ai")}
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 px-3 rounded-md text-sm font-medium transition-colors",
addMode === "ai"
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Sparkles className="h-4 w-4" />
AI Search
</button>
<button
onClick={() => setAddMode("manual")}
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 px-3 rounded-md text-sm font-medium transition-colors",
addMode === "manual"
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<PenLine className="h-4 w-4" />
Manual
</button>
</div>
{addMode === "ai" ? (
<AiDrinkSearch
onAdd={async (drink) => {
await createDrink.mutateAsync({
name: drink.name,
type: drink.type,
subType: drink.subType,
brewery: drink.brewery,
abv: drink.abv,
description: drink.description,
})
}}
/>
) : (
<>
<DrinkForm
onSubmit={handleCreate}
isSubmitting={createDrink.isPending}
submitLabel="Add Drink"
/>
{createDrink.isError && (
<p className="text-sm text-destructive">
{createDrink.error.message || "Failed to create drink"}
</p>
)}
</>
)}
</DialogContent>
</Dialog>
</div>
)
}

14
src/app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { Sidebar } from "@/components/layout/sidebar"
import { BottomNav } from "@/components/layout/bottom-nav"
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-background">
<Sidebar />
<div className="md:pl-64">
<main className="pb-20 md:pb-0">{children}</main>
</div>
<BottomNav />
</div>
)
}

View File

@@ -0,0 +1,154 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { Header } from "@/components/layout/header"
import { RatingForm } from "@/components/ratings/rating-form"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { useCreateRating } from "@/hooks/use-ratings"
import { ArrowLeft, Wine } from "lucide-react"
import Link from "next/link"
interface DrinkInfo {
id: string
name: string
type: string
subType: string | null
brewery: string | null
region: string | null
abv: number | null
imageUrl: string | null
}
export default function RateDrinkPage() {
const params = useParams()
const router = useRouter()
const drinkId = params.drinkId as string
const [drink, setDrink] = useState<DrinkInfo | null>(null)
const [isLoadingDrink, setIsLoadingDrink] = useState(true)
const [loadError, setLoadError] = useState("")
const createRating = useCreateRating()
useEffect(() => {
async function fetchDrink() {
try {
const res = await fetch(`/api/drinks/${drinkId}`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || "Failed to load drink")
}
const data = await res.json()
setDrink(data)
} catch (err) {
setLoadError(
err instanceof Error ? err.message : "Failed to load drink"
)
} finally {
setIsLoadingDrink(false)
}
}
if (drinkId) {
fetchDrink()
}
}, [drinkId])
const handleSubmit = async (data: {
score: number
notes?: string
wouldReorder: boolean
location?: string
}) => {
await createRating.mutateAsync({
drinkId,
score: data.score,
notes: data.notes,
wouldReorder: data.wouldReorder,
location: data.location,
})
router.push(`/drinks/${drinkId}`)
}
return (
<div>
<Header title="Rate Drink" />
<div className="p-4 md:p-8 max-w-2xl mx-auto space-y-6">
{/* Back link */}
<Link
href={drink ? `/drinks/${drinkId}` : "/drinks"}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to {drink ? drink.name : "drinks"}
</Link>
{/* Drink Info Card */}
<Card>
<CardHeader className="pb-3">
{isLoadingDrink ? (
<div className="space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32" />
</div>
) : loadError ? (
<div className="text-destructive">
<p className="font-medium">Could not load drink</p>
<p className="text-sm">{loadError}</p>
</div>
) : drink ? (
<>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Wine className="h-5 w-5 text-primary" />
</div>
<div className="space-y-1">
<CardTitle className="text-xl">{drink.name}</CardTitle>
<CardDescription className="flex items-center gap-2 flex-wrap">
<Badge variant="secondary">{drink.type}</Badge>
{drink.subType && (
<span>{drink.subType}</span>
)}
{drink.brewery && (
<span className="text-muted-foreground">
{drink.brewery}
</span>
)}
{drink.abv != null && (
<span className="text-muted-foreground">
{drink.abv}% ABV
</span>
)}
</CardDescription>
</div>
</div>
</>
) : null}
</CardHeader>
</Card>
{/* Rating Form */}
{!isLoadingDrink && !loadError && drink && (
<Card>
<CardHeader>
<CardTitle>Your Rating</CardTitle>
<CardDescription>
How would you rate {drink.name}?
</CardDescription>
</CardHeader>
<CardContent>
<RatingForm
onSubmit={handleSubmit}
isLoading={createRating.isPending}
submitLabel="Save Rating"
/>
</CardContent>
</Card>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,201 @@
"use client"
import { useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { Header } from "@/components/layout/header"
import { MenuItemCard } from "@/components/scan/menu-item-card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { useScan, useAddDrinkFromScan } from "@/hooks/use-scan"
import { Loader2, ArrowLeft, CheckCircle, AlertCircle, Sparkles, Wine } from "lucide-react"
export default function ScanResultPage() {
const params = useParams()
const router = useRouter()
const scanId = params.id as string
const { data: scan, isLoading, isError } = useScan(scanId)
const addDrink = useAddDrinkFromScan()
const [addingItemIds, setAddingItemIds] = useState<Set<string>>(new Set())
const [addedItemIds, setAddedItemIds] = useState<Set<string>>(new Set())
async function handleAddFromScan(item: { id: string; name: string; type: string; subType?: string | null; brewery?: string | null; abv?: number | null; description?: string | null }) {
if (addingItemIds.has(item.id) || addedItemIds.has(item.id)) return
setAddingItemIds((prev) => new Set(prev).add(item.id))
try {
await addDrink.mutateAsync({
name: item.name,
type: item.type,
subType: item.subType || undefined,
brewery: item.brewery || undefined,
abv: item.abv || undefined,
description: item.description || undefined,
})
setAddedItemIds((prev) => new Set(prev).add(item.id))
} finally {
setAddingItemIds((prev) => {
const next = new Set(prev)
next.delete(item.id)
return next
})
}
}
if (isLoading) {
return (
<div>
<Header title="Scan Results" />
<div className="p-4 md:p-8 max-w-2xl mx-auto space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-[200px] w-full" />
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
</div>
)
}
if (isError || !scan) {
return (
<div>
<Header title="Scan Results" />
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-12">
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
<p className="text-lg font-medium">Failed to load scan results</p>
<Button variant="outline" className="mt-4" onClick={() => router.push("/scan")}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Scan
</Button>
</div>
</div>
)
}
const isProcessing = scan.status === "PROCESSING" || scan.status === "UPLOADING"
const isFailed = scan.status === "FAILED"
const matchedItems = scan.items?.filter((item) => item.matchedDrinkId) || []
const recommendedItems = scan.items?.filter((item) => item.aiRecommended && !item.matchedDrinkId) || []
const otherItems = scan.items?.filter((item) => !item.matchedDrinkId && !item.aiRecommended) || []
return (
<div>
<Header title="Scan Results" />
<div className="p-4 md:p-8 max-w-2xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => router.push("/scan")}>
<ArrowLeft className="h-4 w-4 mr-1" />
Back
</Button>
</div>
{isProcessing && (
<div className="flex flex-col items-center gap-4 py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="text-center">
<p className="text-lg font-medium">Analyzing your menu...</p>
<p className="text-sm text-muted-foreground">
Our AI is extracting drinks and finding recommendations for you
</p>
</div>
</div>
)}
{isFailed && (
<div className="flex flex-col items-center gap-4 py-12">
<AlertCircle className="h-12 w-12 text-destructive" />
<div className="text-center">
<p className="text-lg font-medium">Analysis failed</p>
<p className="text-sm text-muted-foreground">
{scan.errorMessage || "Something went wrong. Please try again."}
</p>
</div>
<Button onClick={() => router.push("/scan")}>Try Again</Button>
</div>
)}
{scan.status === "COMPLETED" && (
<>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="gap-1">
<CheckCircle className="h-3 w-3" />
{scan.items?.length || 0} drinks found
</Badge>
{matchedItems.length > 0 && (
<Badge variant="secondary" className="gap-1">
<Wine className="h-3 w-3" />
{matchedItems.length} you&apos;ve tried
</Badge>
)}
{recommendedItems.length > 0 && (
<Badge className="gap-1">
<Sparkles className="h-3 w-3" />
{recommendedItems.length} recommended
</Badge>
)}
</div>
{/* Drinks you've tried */}
{matchedItems.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
<Wine className="h-5 w-5 text-green-600" />
Drinks You&apos;ve Tried
</h2>
<div className="space-y-2">
{matchedItems.map((item) => (
<MenuItemCard
key={item.id}
{...item}
onQuickRate={() => router.push(`/rate/${item.matchedDrinkId}`)}
/>
))}
</div>
</div>
)}
{/* AI Recommendations */}
{recommendedItems.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
Recommended For You
</h2>
<div className="space-y-2">
{recommendedItems.map((item) => (
<MenuItemCard
key={item.id}
{...item}
onAddToDrinks={() => handleAddFromScan(item)}
isAddingToDrinks={addingItemIds.has(item.id)}
wasAddedToDrinks={addedItemIds.has(item.id)}
/>
))}
</div>
</div>
)}
{/* Other items */}
{otherItems.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3">Other Menu Items</h2>
<div className="space-y-2">
{otherItems.map((item) => (
<MenuItemCard
key={item.id}
{...item}
onAddToDrinks={() => handleAddFromScan(item)}
isAddingToDrinks={addingItemIds.has(item.id)}
wasAddedToDrinks={addedItemIds.has(item.id)}
/>
))}
</div>
</div>
)}
</>
)}
</div>
</div>
)
}

103
src/app/(app)/scan/page.tsx Normal file
View File

@@ -0,0 +1,103 @@
"use client"
import { useRouter } from "next/navigation"
import { Header } from "@/components/layout/header"
import { PhotoUpload } from "@/components/scan/photo-upload"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { useCreateScan, useScans } from "@/hooks/use-scan"
import { Skeleton } from "@/components/ui/skeleton"
import { Clock, CheckCircle, AlertCircle, Loader2 } from "lucide-react"
import Link from "next/link"
export default function ScanPage() {
const router = useRouter()
const createScan = useCreateScan()
const { data: scansData, isLoading } = useScans()
const handleUpload = async (file: File) => {
try {
const scan = await createScan.mutateAsync(file)
router.push(`/scan/${scan.id}`)
} catch (error) {
console.error("Scan failed:", error)
}
}
const statusIcon = {
UPLOADING: <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />,
PROCESSING: <Loader2 className="h-4 w-4 animate-spin text-primary" />,
COMPLETED: <CheckCircle className="h-4 w-4 text-green-500" />,
FAILED: <AlertCircle className="h-4 w-4 text-destructive" />,
}
return (
<div>
<Header title="Scan Menu" />
<div className="p-4 md:p-8 space-y-6 max-w-2xl mx-auto">
<div>
<h1 className="text-2xl font-bold">Scan a Menu</h1>
<p className="text-muted-foreground">
Take a photo of a beer or wine menu to identify drinks and get personalized recommendations
</p>
</div>
<PhotoUpload
onUpload={handleUpload}
isUploading={createScan.isPending}
/>
{createScan.isError && (
<div className="text-sm text-destructive">
{createScan.error.message}
</div>
)}
{/* Recent Scans */}
<div>
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
<Clock className="h-5 w-5" />
Recent Scans
</h2>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : scansData?.scans?.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
No scans yet. Upload a menu photo to get started!
</p>
) : (
<div className="space-y-2">
{scansData?.scans?.map((scan: { id: string; status: string; items?: { length: number }[]; createdAt: string }) => (
<Link key={scan.id} href={`/scan/${scan.id}`}>
<Card className="hover:bg-accent/50 transition-colors cursor-pointer">
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
{statusIcon[scan.status as keyof typeof statusIcon]}
<div>
<p className="text-sm font-medium">
{scan.items?.length || 0} items found
</p>
<p className="text-xs text-muted-foreground">
{new Date(scan.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<Badge variant={scan.status === "COMPLETED" ? "default" : "secondary"}>
{scan.status}
</Badge>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,339 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Header } from "@/components/layout/header"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { Key, Trash2, Check, Loader2, Shield, Sliders } from "lucide-react"
import { BackupRestore } from "@/components/settings/backup-restore"
interface ApiKeyInfo {
id: string
provider: string
label?: string
maskedKey: string
isActive: boolean
}
export default function SettingsPage() {
const queryClient = useQueryClient()
// API Keys
const { data: apiKeys = [] } = useQuery<ApiKeyInfo[]>({
queryKey: ["api-keys"],
queryFn: async () => {
const res = await fetch("/api/settings/api-keys")
if (!res.ok) throw new Error("Failed to fetch API keys")
return res.json()
},
})
// Preferences
const { data: preferences, isLoading: prefsLoading } = useQuery({
queryKey: ["preferences"],
queryFn: async () => {
const res = await fetch("/api/settings/preferences")
if (!res.ok) throw new Error("Failed to fetch preferences")
return res.json()
},
})
const savePreferences = useMutation({
mutationFn: async (prefs: Record<string, unknown>) => {
const res = await fetch("/api/settings/preferences", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(prefs),
})
if (!res.ok) throw new Error("Failed to save preferences")
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["preferences"] })
},
})
return (
<div>
<Header title="Settings" />
<div className="p-4 md:p-8 max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-muted-foreground">
Manage your AI providers and preferences
</p>
</div>
{/* API Keys Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
AI Provider Keys
</CardTitle>
<CardDescription>
Add your API keys for AI-powered menu scanning. Keys are encrypted and stored securely.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ApiKeyForm provider="claude" label="Anthropic Claude" existingKey={apiKeys.find(k => k.provider === "claude")} />
<Separator />
<ApiKeyForm provider="openai" label="OpenAI GPT-4o" existingKey={apiKeys.find(k => k.provider === "openai")} />
</CardContent>
</Card>
{/* Preferences Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sliders className="h-5 w-5" />
Drink Preferences
</CardTitle>
<CardDescription>
Help the AI make better recommendations by telling it what you like
</CardDescription>
</CardHeader>
<CardContent>
<PreferencesForm
preferences={preferences}
isLoading={prefsLoading}
onSave={(prefs) => savePreferences.mutate(prefs)}
isSaving={savePreferences.isPending}
/>
</CardContent>
</Card>
{/* Backup & Restore Section */}
<BackupRestore />
</div>
</div>
)
}
function ApiKeyForm({
provider,
label,
existingKey,
}: {
provider: string
label: string
existingKey?: ApiKeyInfo
}) {
const [apiKey, setApiKey] = useState("")
const [isEditing, setIsEditing] = useState(false)
const queryClient = useQueryClient()
const saveKey = useMutation({
mutationFn: async () => {
const res = await fetch("/api/settings/api-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, apiKey }),
})
if (!res.ok) throw new Error("Failed to save API key")
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["api-keys"] })
setApiKey("")
setIsEditing(false)
},
})
const deleteKey = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/settings/api-keys?provider=${provider}`, {
method: "DELETE",
})
if (!res.ok) throw new Error("Failed to delete API key")
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["api-keys"] })
},
})
if (existingKey && !isEditing) {
return (
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{label}</p>
<div className="flex items-center gap-2 mt-1">
<code className="text-sm bg-muted px-2 py-0.5 rounded">
{existingKey.maskedKey}
</code>
<Badge variant="outline" className="text-xs text-green-600">
<Check className="h-3 w-3 mr-1" />
Active
</Badge>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
Update
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteKey.mutate()}
disabled={deleteKey.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
)
}
return (
<div className="space-y-3">
<p className="font-medium">{label}</p>
<div className="flex gap-2">
<Input
type="password"
placeholder={`Enter your ${label} API key`}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<Button
onClick={() => saveKey.mutate()}
disabled={!apiKey || saveKey.isPending}
>
{saveKey.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Save"
)}
</Button>
{isEditing && (
<Button variant="ghost" onClick={() => setIsEditing(false)}>
Cancel
</Button>
)}
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Shield className="h-3 w-3" />
Your key is encrypted before storage and never exposed in full
</p>
</div>
)
}
function PreferencesForm({
preferences,
isLoading,
onSave,
isSaving,
}: {
preferences: Record<string, unknown> | undefined
isLoading: boolean
onSave: (prefs: Record<string, unknown>) => void
isSaving: boolean
}) {
const [preferredStyles, setPreferredStyles] = useState("")
const [avoidedStyles, setAvoidedStyles] = useState("")
const [minAbv, setMinAbv] = useState("")
const [maxAbv, setMaxAbv] = useState("")
const [initialized, setInitialized] = useState(false)
if (preferences && !initialized) {
const prefs = preferences as { preferredStyles?: string[]; avoidedStyles?: string[]; minAbv?: number; maxAbv?: number }
setPreferredStyles(prefs.preferredStyles?.join(", ") || "")
setAvoidedStyles(prefs.avoidedStyles?.join(", ") || "")
setMinAbv(prefs.minAbv?.toString() || "")
setMaxAbv(prefs.maxAbv?.toString() || "")
setInitialized(true)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSave({
preferredStyles: preferredStyles
.split(",")
.map((s) => s.trim())
.filter(Boolean),
avoidedStyles: avoidedStyles
.split(",")
.map((s) => s.trim())
.filter(Boolean),
minAbv: minAbv ? parseFloat(minAbv) : null,
maxAbv: maxAbv ? parseFloat(maxAbv) : null,
})
}
if (isLoading) return <div className="space-y-3"><p className="text-sm text-muted-foreground">Loading...</p></div>
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="preferred">Preferred Styles</Label>
<Input
id="preferred"
placeholder="e.g., IPA, Stout, Pinot Noir, Malbec"
value={preferredStyles}
onChange={(e) => setPreferredStyles(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
Comma-separated list of styles you enjoy
</p>
</div>
<div>
<Label htmlFor="avoided">Avoided Styles</Label>
<Input
id="avoided"
placeholder="e.g., Sour, Light Lager, Ros&eacute;"
value={avoidedStyles}
onChange={(e) => setAvoidedStyles(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
Comma-separated list of styles you want to avoid
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="minAbv">Min ABV %</Label>
<Input
id="minAbv"
type="number"
step="0.1"
min="0"
max="100"
placeholder="e.g., 4.0"
value={minAbv}
onChange={(e) => setMinAbv(e.target.value)}
/>
</div>
<div>
<Label htmlFor="maxAbv">Max ABV %</Label>
<Input
id="maxAbv"
type="number"
step="0.1"
min="0"
max="100"
placeholder="e.g., 12.0"
value={maxAbv}
onChange={(e) => setMaxAbv(e.target.value)}
/>
</div>
</div>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Saving...
</>
) : (
"Save Preferences"
)}
</Button>
</form>
)
}

View File

@@ -0,0 +1,146 @@
"use client"
import { useState } from "react"
import { Header } from "@/components/layout/header"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import {
useWishlist,
useRemoveFromWishlist,
usePromoteWishlistItem,
} from "@/hooks/use-wishlist"
import { Bookmark, Trash2, ArrowRight, Wine } from "lucide-react"
import { cn } from "@/lib/utils"
const typeColors: Record<string, string> = {
BEER: "bg-amber-100 text-amber-800",
WINE: "bg-purple-100 text-purple-800",
COCKTAIL: "bg-blue-100 text-blue-800",
SPIRIT: "bg-orange-100 text-orange-800",
OTHER: "bg-gray-100 text-gray-800",
}
export default function WishlistPage() {
const { data, isLoading, error } = useWishlist()
const removeItem = useRemoveFromWishlist()
const promoteItem = usePromoteWishlistItem()
const [promotingId, setPromotingId] = useState<string | null>(null)
function handlePromote(id: string) {
setPromotingId(id)
promoteItem.mutate(id, {
onSettled: () => setPromotingId(null),
})
}
return (
<div>
<Header title="Try Later" />
<div className="p-4 md:p-8 space-y-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Bookmark className="h-6 w-6" />
Try Later
</h1>
<p className="text-muted-foreground">
Drinks you want to try. Add them to your collection when you do.
</p>
</div>
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-24 w-full rounded-lg" />
))}
</div>
) : error ? (
<div className="text-center py-12">
<p className="text-destructive">Failed to load wishlist.</p>
</div>
) : data?.items.length === 0 ? (
<div className="text-center py-16 space-y-4">
<Wine className="h-12 w-12 mx-auto text-muted-foreground/50" />
<div>
<h3 className="text-lg font-semibold">Nothing saved yet</h3>
<p className="text-muted-foreground mt-1">
When you find a drink you want to try later, bookmark it from a
menu scan or AI search.
</p>
</div>
</div>
) : (
<div className="space-y-3">
{data?.items.map((item) => (
<Card key={item.id}>
<CardContent className="flex items-start justify-between gap-3 p-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold">{item.name}</h3>
<Badge
variant="secondary"
className={cn("text-xs", typeColors[item.type])}
>
{item.type}
</Badge>
{item.subType && (
<Badge variant="outline" className="text-xs">
{item.subType}
</Badge>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
{item.brewery && <span>{item.brewery}</span>}
{item.abv != null && (
<span>
{item.brewery ? "·" : ""} {item.abv}% ABV
</span>
)}
</div>
{item.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{item.description}
</p>
)}
{item.notes && (
<p className="text-sm text-muted-foreground/80 mt-1 italic">
{item.notes}
</p>
)}
{item.source && (
<Badge variant="outline" className="text-xs mt-2">
from {item.source}
</Badge>
)}
</div>
<div className="flex flex-col gap-1">
<Button
size="sm"
variant="default"
onClick={() => handlePromote(item.id)}
disabled={promotingId === item.id}
>
<ArrowRight className="h-3 w-3 mr-1" />
Tried it
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => removeItem.mutate(item.id)}
disabled={removeItem.isPending}
>
<Trash2 className="h-3 w-3 mr-1" />
Remove
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,150 @@
"use client"
import { useState } from "react"
import { signIn } from "next-auth/react"
import Link from "next/link"
import { Beer, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
export default function LoginPage() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setError("")
setLoading(true)
try {
const result = await signIn("credentials", {
email,
password,
callbackUrl: "/dashboard",
redirect: false,
})
if (result?.error) {
setError("Invalid email or password")
setLoading(false)
} else if (result?.url) {
window.location.href = result.url
}
} catch {
setError("Something went wrong. Please try again.")
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<Beer className="h-12 w-12 text-primary" />
</div>
<CardTitle className="text-2xl">DrinkTracker</CardTitle>
<CardDescription>
Track, rate, and discover your favorite drinks with AI-powered menu scanning
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Sign In
</Button>
</form>
<div className="flex items-center gap-3 py-4">
<Separator className="flex-1" />
<span className="text-xs text-muted-foreground">or</span>
<Separator className="flex-1" />
</div>
<div className="space-y-3">
<Button
variant="outline"
className="w-full"
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => signIn("github", { callbackUrl: "/dashboard" })}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
Continue with GitHub
</Button>
</div>
<p className="mt-4 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/register" className="text-primary underline-offset-4 hover:underline">
Sign up
</Link>
</p>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,154 @@
"use client"
import { useState } from "react"
import { signIn } from "next-auth/react"
import Link from "next/link"
import { Beer, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
export default function RegisterPage() {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError("")
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (password.length < 8) {
setError("Password must be at least 8 characters")
return
}
setLoading(true)
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || "Registration failed")
setLoading(false)
return
}
// Auto sign in after successful registration
await signIn("credentials", {
email,
password,
callbackUrl: "/dashboard",
})
} catch {
setError("Something went wrong. Please try again.")
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<Beer className="h-12 w-12 text-primary" />
</div>
<CardTitle className="text-2xl">DrinkTracker</CardTitle>
<CardDescription>
Create an account to start tracking your drinks
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
required
maxLength={100}
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
disabled={loading}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Create Account
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
Sign in
</Link>
</p>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const searches = await prisma.searchCache.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
take: 10,
select: {
id: true,
query: true,
provider: true,
results: true,
createdAt: true,
},
})
return NextResponse.json({ searches })
}

View File

@@ -0,0 +1,102 @@
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"
import type { Prisma } from "@prisma/client"
const searchSchema = z.object({
query: z.string().min(1).max(200),
})
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Rate limit: 20 searches per minute
const { success: withinLimit } = rateLimit(`ai-search:${session.user.id}`, 20, 60 * 1000)
if (!withinLimit) {
return NextResponse.json(
{ error: "Too many requests. Please wait a moment." },
{ status: 429 }
)
}
try {
const body = await request.json()
const parsed = searchSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: "Invalid query" }, { status: 400 })
}
// Get user's AI provider
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 }
)
}
// Check cache first (24hr TTL)
const queryHash = parsed.data.query.toLowerCase().trim()
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
const cached = await prisma.searchCache.findUnique({
where: {
userId_queryHash_provider: {
userId: session.user.id,
queryHash,
provider: apiKeyRecord.provider,
},
},
})
if (cached && cached.createdAt > twentyFourHoursAgo) {
return NextResponse.json(cached.results)
}
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
const result = await provider.searchDrinks(parsed.data.query)
// Cache the result
await prisma.searchCache.upsert({
where: {
userId_queryHash_provider: {
userId: session.user.id,
queryHash,
provider: apiKeyRecord.provider,
},
},
update: {
query: parsed.data.query,
results: { drinks: result.drinks } as unknown as Prisma.InputJsonValue,
createdAt: new Date(),
},
create: {
userId: session.user.id,
queryHash,
query: parsed.data.query,
results: { drinks: result.drinks } as unknown as Prisma.InputJsonValue,
provider: apiKeyRecord.provider,
},
})
return NextResponse.json({ drinks: result.drinks })
} catch (error) {
console.error("AI search error:", error)
return NextResponse.json(
{ error: "Search failed. Please try again." },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth"
export const { GET, POST } = handlers

View File

@@ -0,0 +1,64 @@
import { NextResponse } from "next/server"
import { z } from "zod"
import bcrypt from "bcryptjs"
import { prisma } from "@/lib/prisma"
const registerSchema = z.object({
name: z
.string()
.min(1, "Name is required")
.max(100, "Name must be 100 characters or less"),
email: z
.string()
.min(1, "Email is required")
.email("Invalid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters"),
})
export async function POST(request: Request) {
try {
const body = await request.json()
const result = registerSchema.safeParse(body)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
return NextResponse.json(
{ error: "Validation failed", details: errors },
{ status: 400 }
)
}
const { name, email, password } = result.data
const existingUser = await prisma.user.findUnique({ where: { email } })
if (existingUser) {
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 409 }
)
}
const hashedPassword = await bcrypt.hash(password, 10)
const user = await prisma.user.create({
data: {
name,
email,
password: hashedPassword,
emailVerified: new Date(),
},
})
return NextResponse.json(
{ id: user.id, name: user.name, email: user.email },
{ status: 201 }
)
} catch {
return NextResponse.json(
{ error: "Something went wrong. Please try again." },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { drinkUpdateSchema } from "@/lib/validators"
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const drink = await prisma.drink.findUnique({
where: { id: params.id },
include: {
ratings: {
orderBy: { createdAt: "desc" },
},
},
})
if (!drink) {
return NextResponse.json({ error: "Drink not found" }, { status: 404 })
}
if (drink.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
// Compute average rating
const scores = drink.ratings.map((r) => r.score)
const avgRating =
scores.length > 0
? scores.reduce((sum, s) => sum + s, 0) / scores.length
: null
return NextResponse.json({
...drink,
avgRating,
ratingCount: scores.length,
})
} catch (error) {
console.error("GET /api/drinks/[id] error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Check ownership
const existing = await prisma.drink.findUnique({
where: { id: params.id },
select: { userId: true },
})
if (!existing) {
return NextResponse.json({ error: "Drink not found" }, { status: 404 })
}
if (existing.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const body = await request.json()
const parsed = drinkUpdateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", issues: parsed.error.issues },
{ status: 400 }
)
}
const drink = await prisma.drink.update({
where: { id: params.id },
data: parsed.data,
})
return NextResponse.json(drink)
} catch (error) {
console.error("PUT /api/drinks/[id] error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Check ownership
const existing = await prisma.drink.findUnique({
where: { id: params.id },
select: { userId: true },
})
if (!existing) {
return NextResponse.json({ error: "Drink not found" }, { status: 404 })
}
if (existing.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
await prisma.drink.delete({
where: { id: params.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("DELETE /api/drinks/[id] error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

140
src/app/api/drinks/route.ts Normal file
View File

@@ -0,0 +1,140 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { drinkCreateSchema } from "@/lib/validators"
import { DrinkType, Prisma } from "@prisma/client"
export async function GET(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const search = searchParams.get("search") || ""
const type = searchParams.get("type") || ""
const sort = searchParams.get("sort") || "recent"
const page = parseInt(searchParams.get("page") || "1", 10)
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 100)
const skip = (page - 1) * limit
// Build where clause
const where: Prisma.DrinkWhereInput = {
userId: session.user.id,
}
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ brewery: { contains: search, mode: "insensitive" } },
{ subType: { contains: search, mode: "insensitive" } },
{ region: { contains: search, mode: "insensitive" } },
]
}
if (type && type !== "ALL") {
where.type = type as DrinkType
}
// Build orderBy
let orderBy: Prisma.DrinkOrderByWithRelationInput = { createdAt: "desc" }
if (sort === "name") {
orderBy = { name: "asc" }
} else if (sort === "rating") {
orderBy = { ratings: { _count: "desc" } }
}
const [drinks, total] = await Promise.all([
prisma.drink.findMany({
where,
include: {
ratings: {
select: { score: true },
},
},
orderBy,
skip,
take: limit,
}),
prisma.drink.count({ where }),
])
// Compute average rating for each drink
const drinksWithAvgRating = drinks.map((drink) => {
const scores = drink.ratings.map((r) => r.score)
const avgRating =
scores.length > 0
? scores.reduce((sum, s) => sum + s, 0) / scores.length
: null
return {
...drink,
avgRating,
ratingCount: scores.length,
ratings: undefined, // Remove raw ratings from list response
}
})
// If sorting by rating, sort in memory since Prisma doesn't support
// ordering by aggregate of relation in this way
if (sort === "rating") {
drinksWithAvgRating.sort((a, b) => {
if (a.avgRating === null && b.avgRating === null) return 0
if (a.avgRating === null) return 1
if (b.avgRating === null) return -1
return b.avgRating - a.avgRating
})
}
return NextResponse.json({
drinks: drinksWithAvgRating,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
} catch (error) {
console.error("GET /api/drinks error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const body = await request.json()
const parsed = drinkCreateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", issues: parsed.error.issues },
{ status: 400 }
)
}
const drink = await prisma.drink.create({
data: {
...parsed.data,
userId: session.user.id,
},
})
return NextResponse.json(drink, { status: 201 })
} catch (error) {
console.error("POST /api/drinks error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,164 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { ratingUpdateSchema } from "@/lib/validators"
type RouteContext = {
params: { id: string }
}
export async function GET(
request: NextRequest,
{ params }: RouteContext
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const rating = await prisma.rating.findFirst({
where: {
id: params.id,
userId: session.user.id,
},
include: {
drink: {
select: {
id: true,
name: true,
type: true,
subType: true,
brewery: true,
region: true,
abv: true,
imageUrl: true,
},
},
},
})
if (!rating) {
return NextResponse.json(
{ error: "Rating not found" },
{ status: 404 }
)
}
return NextResponse.json(rating)
} catch (error) {
console.error("GET /api/ratings/[id] error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
export async function PUT(
request: NextRequest,
{ params }: RouteContext
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Verify ownership
const existing = await prisma.rating.findFirst({
where: {
id: params.id,
userId: session.user.id,
},
})
if (!existing) {
return NextResponse.json(
{ error: "Rating not found" },
{ status: 404 }
)
}
const body = await request.json()
const parsed = ratingUpdateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 }
)
}
const { score, notes, wouldReorder, location } = parsed.data
const rating = await prisma.rating.update({
where: { id: params.id },
data: {
...(score !== undefined && { score }),
...(notes !== undefined && { notes: notes || null }),
...(wouldReorder !== undefined && { wouldReorder }),
...(location !== undefined && { location: location || null }),
},
include: {
drink: {
select: {
id: true,
name: true,
type: true,
subType: true,
brewery: true,
imageUrl: true,
},
},
},
})
return NextResponse.json(rating)
} catch (error) {
console.error("PUT /api/ratings/[id] error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
export async function DELETE(
request: NextRequest,
{ params }: RouteContext
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Verify ownership
const existing = await prisma.rating.findFirst({
where: {
id: params.id,
userId: session.user.id,
},
})
if (!existing) {
return NextResponse.json(
{ error: "Rating not found" },
{ status: 404 }
)
}
await prisma.rating.delete({
where: { id: params.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("DELETE /api/ratings/[id] error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,146 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { ratingCreateSchema } from "@/lib/validators"
export async function GET(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const drinkId = searchParams.get("drinkId")
const page = Math.max(1, parseInt(searchParams.get("page") || "1"))
const limit = Math.min(50, Math.max(1, parseInt(searchParams.get("limit") || "20")))
const sort = searchParams.get("sort") || "recent"
const where: { userId: string; drinkId?: string } = {
userId: session.user.id,
}
if (drinkId) {
where.drinkId = drinkId
}
let orderBy: Record<string, string>
switch (sort) {
case "score-high":
orderBy = { score: "desc" }
break
case "score-low":
orderBy = { score: "asc" }
break
case "recent":
default:
orderBy = { createdAt: "desc" }
break
}
const [ratings, total] = await Promise.all([
prisma.rating.findMany({
where,
include: {
drink: {
select: {
id: true,
name: true,
type: true,
subType: true,
brewery: true,
imageUrl: true,
},
},
},
orderBy,
skip: (page - 1) * limit,
take: limit,
}),
prisma.rating.count({ where }),
])
return NextResponse.json({
ratings,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
} catch (error) {
console.error("GET /api/ratings error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const body = await request.json()
const parsed = ratingCreateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 }
)
}
const { drinkId, score, notes, wouldReorder, location } = parsed.data
// Verify the drink belongs to the current user
const drink = await prisma.drink.findFirst({
where: {
id: drinkId,
userId: session.user.id,
},
})
if (!drink) {
return NextResponse.json(
{ error: "Drink not found" },
{ status: 404 }
)
}
const rating = await prisma.rating.create({
data: {
userId: session.user.id,
drinkId,
score,
notes: notes || null,
wouldReorder: wouldReorder ?? false,
location: location || null,
},
include: {
drink: {
select: {
id: true,
name: true,
type: true,
subType: true,
brewery: true,
imageUrl: true,
},
},
},
})
return NextResponse.json(rating, { status: 201 })
} catch (error) {
console.error("POST /api/ratings error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,65 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const scan = await prisma.menuScan.findUnique({
where: { id: params.id },
include: {
items: {
include: {
matchedDrink: {
include: {
ratings: {
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
take: 1,
},
},
},
},
orderBy: [
{ aiRecommended: "desc" },
{ matchedDrinkId: "asc" },
{ name: "asc" },
],
},
},
})
if (!scan || scan.userId !== session.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
return NextResponse.json(scan)
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const scan = await prisma.menuScan.findUnique({
where: { id: params.id },
})
if (!scan || scan.userId !== session.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
await prisma.menuScan.delete({ where: { id: params.id } })
return NextResponse.json({ success: true })
}

178
src/app/api/scan/route.ts Normal file
View File

@@ -0,0 +1,178 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { uploadImage } from "@/lib/s3"
import { rateLimit } from "@/lib/rate-limit"
import { randomUUID } from "crypto"
export async function GET(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get("page") || "1")
const limit = parseInt(searchParams.get("limit") || "20")
const [scans, total] = await Promise.all([
prisma.menuScan.findMany({
where: { userId: session.user.id },
include: {
items: {
include: {
matchedDrink: {
include: { ratings: { where: { userId: session.user.id }, take: 1, orderBy: { createdAt: "desc" } } },
},
},
},
},
orderBy: { createdAt: "desc" },
skip: (page - 1) * limit,
take: limit,
}),
prisma.menuScan.count({ where: { userId: session.user.id } }),
])
return NextResponse.json({ scans, total, page, limit })
}
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Rate limit: 10 scans per minute per user
const { success: withinLimit } = rateLimit(`scan:${session.user.id}`, 10, 60 * 1000)
if (!withinLimit) {
return NextResponse.json(
{ error: "Too many requests. Please wait before scanning again." },
{ status: 429 }
)
}
try {
const formData = await request.formData()
const file = formData.get("file") as File | null
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 })
}
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/heic"]
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: "Invalid file type" },
{ status: 400 }
)
}
const buffer = Buffer.from(await file.arrayBuffer())
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1]
const key = `scans/${session.user.id}/${randomUUID()}.${ext}`
const imageUrl = await uploadImage(key, buffer, file.type)
const scan = await prisma.menuScan.create({
data: {
userId: session.user.id,
imageUrl,
status: "PROCESSING",
},
})
// Kick off async processing - don't await
processMenuScan(scan.id, buffer, file.type, session.user.id).catch(
(error) => console.error("Scan processing error:", error)
)
return NextResponse.json(scan, { status: 201 })
} catch (error) {
console.error("Scan creation error:", error)
return NextResponse.json(
{ error: "Failed to create scan" },
{ status: 500 }
)
}
}
async function processMenuScan(
scanId: string,
imageBuffer: Buffer,
mimeType: string,
userId: string
) {
try {
const { analyzeMenu } = await import("@/lib/ai/menu-analyzer")
const imageBase64 = imageBuffer.toString("base64")
const result = await analyzeMenu(imageBase64, mimeType, userId)
// Get user's drinks for matching
const userDrinks = await prisma.drink.findMany({
where: { userId },
include: {
ratings: {
orderBy: { createdAt: "desc" },
take: 1,
},
},
})
// Create menu items
const menuItems = result.extractedItems.map((item) => {
// Try to match against user's collection
const match = userDrinks.find(
(d) =>
d.name.toLowerCase() === item.name.toLowerCase() ||
(d.brewery &&
item.brewery &&
d.name.toLowerCase().includes(item.name.toLowerCase().split(" ")[0]) &&
d.brewery.toLowerCase() === item.brewery.toLowerCase())
)
const recommendation = result.recommendations.recommendations.find(
(r) => r.itemName.toLowerCase() === item.name.toLowerCase()
)
return {
scanId,
name: item.name,
type: item.type,
subType: item.subType,
brewery: item.brewery,
abv: item.abv,
price: item.price,
description: item.description,
matchedDrinkId: match?.id || null,
userRating: match?.ratings[0]?.score || null,
aiRecommended: !!recommendation,
aiReason: recommendation?.reason || null,
}
})
await prisma.$transaction([
prisma.menuItem.createMany({ data: menuItems }),
prisma.menuScan.update({
where: { id: scanId },
data: {
status: "COMPLETED",
aiProvider: result.provider,
aiRawResponse: JSON.parse(JSON.stringify(result.rawResponse)),
processedAt: new Date(),
},
}),
])
} catch (error) {
console.error("Menu scan processing failed:", error)
await prisma.menuScan.update({
where: { id: scanId },
data: {
status: "FAILED",
errorMessage:
error instanceof Error ? error.message : "Unknown error",
},
})
}
}

View File

@@ -0,0 +1,130 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { encrypt, decrypt, maskApiKey } from "@/lib/encryption"
import { apiKeySchema } from "@/lib/validators"
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const apiKeys = await prisma.userApiKey.findMany({
where: { userId: session.user.id },
select: {
id: true,
provider: true,
label: true,
isActive: true,
createdAt: true,
updatedAt: true,
encryptedKey: true,
iv: true,
},
})
// Return masked keys
const maskedKeys = apiKeys.map((key) => {
let maskedKey = "****"
try {
const decrypted = decrypt(key.encryptedKey, key.iv)
maskedKey = maskApiKey(decrypted)
} catch {
// If decryption fails, show generic mask
}
return {
id: key.id,
provider: key.provider,
label: key.label,
isActive: key.isActive,
maskedKey,
createdAt: key.createdAt,
updatedAt: key.updatedAt,
}
})
return NextResponse.json(maskedKeys)
}
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const body = await request.json()
const parsed = apiKeySchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
)
}
const { provider, apiKey, label } = parsed.data
const { encrypted, iv } = encrypt(apiKey)
const key = await prisma.userApiKey.upsert({
where: {
userId_provider: {
userId: session.user.id,
provider,
},
},
update: {
encryptedKey: encrypted,
iv,
label,
isActive: true,
},
create: {
userId: session.user.id,
provider,
encryptedKey: encrypted,
iv,
label,
isActive: true,
},
})
return NextResponse.json({
id: key.id,
provider: key.provider,
label: key.label,
maskedKey: maskApiKey(apiKey),
isActive: key.isActive,
})
} catch (error) {
console.error("API key save error:", error)
return NextResponse.json(
{ error: "Failed to save API key" },
{ status: 500 }
)
}
}
export async function DELETE(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const provider = searchParams.get("provider")
if (!provider) {
return NextResponse.json({ error: "Provider required" }, { status: 400 })
}
await prisma.userApiKey.deleteMany({
where: {
userId: session.user.id,
provider,
},
})
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,60 @@
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { generateBackupCsv } from "@/lib/backup"
import { NextResponse } from "next/server"
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const userId = session.user.id
try {
const [drinks, ratings, wishlistItems, preferences, sharedLists] =
await Promise.all([
prisma.drink.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
}),
prisma.rating.findMany({
where: { userId },
include: { drink: { select: { name: true } } },
orderBy: { createdAt: "asc" },
}),
prisma.wishlistItem.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
}),
prisma.userPreference.findUnique({ where: { userId } }),
prisma.sharedList.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
}),
])
const csv = generateBackupCsv(
drinks,
ratings,
wishlistItems,
preferences,
sharedLists
)
const date = new Date().toISOString().split("T")[0]
return new Response(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="drinktracker-backup-${date}.csv"`,
},
})
} catch (error) {
console.error("Backup export error:", error)
return NextResponse.json(
{ error: "Failed to generate backup" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,61 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { userPreferenceSchema } from "@/lib/validators"
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const preferences = await prisma.userPreference.findUnique({
where: { userId: session.user.id },
})
return NextResponse.json(preferences || {
preferredStyles: [],
avoidedStyles: [],
minAbv: null,
maxAbv: null,
defaultProvider: null,
})
}
export async function PUT(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const body = await request.json()
const parsed = userPreferenceSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
)
}
const preferences = await prisma.userPreference.upsert({
where: { userId: session.user.id },
update: parsed.data,
create: {
userId: session.user.id,
...parsed.data,
preferredStyles: parsed.data.preferredStyles || [],
avoidedStyles: parsed.data.avoidedStyles || [],
},
})
return NextResponse.json(preferences)
} catch (error) {
console.error("Preferences save error:", error)
return NextResponse.json(
{ error: "Failed to save preferences" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,99 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { csvToObjects } from "@/lib/csv"
import {
parseBackupRows,
validateBackupData,
executeRestore,
} from "@/lib/backup"
const VALID_MODES = ["merge-skip", "merge-update", "replace"] as const
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const formData = await request.formData()
const file = formData.get("file") as File | null
const mode = formData.get("mode") as string | null
if (!file) {
return NextResponse.json(
{ error: "No file provided" },
{ status: 400 }
)
}
if (!mode || !VALID_MODES.includes(mode as (typeof VALID_MODES)[number])) {
return NextResponse.json(
{ error: "Invalid restore mode. Must be: merge-skip, merge-update, or replace" },
{ status: 400 }
)
}
// Check file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
return NextResponse.json(
{ error: "File too large. Maximum size is 10MB." },
{ status: 400 }
)
}
// Parse CSV
const csvText = await file.text()
if (!csvText.trim()) {
return NextResponse.json(
{ error: "File is empty" },
{ status: 400 }
)
}
const rows = csvToObjects(csvText)
if (rows.length === 0) {
return NextResponse.json(
{ error: "No data rows found in CSV" },
{ status: 400 }
)
}
// Check for _type column
if (!("_type" in rows[0])) {
return NextResponse.json(
{ error: "Invalid CSV format: missing _type column" },
{ status: 400 }
)
}
// Parse and validate
const parsed = parseBackupRows(rows)
const validation = validateBackupData(parsed)
if (!validation.valid) {
return NextResponse.json(
{
error: "Validation failed",
details: validation.errors.slice(0, 10).join("; "),
},
{ status: 400 }
)
}
// Execute restore
const summary = await executeRestore(
session.user.id,
parsed,
mode as (typeof VALID_MODES)[number]
)
return NextResponse.json({ success: true, summary })
} catch (error) {
console.error("Restore error:", error)
return NextResponse.json(
{ error: "Restore failed. Your data has not been changed." },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,62 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { sharedListUpdateSchema } from "@/lib/validators"
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { id } = await params
const list = await prisma.sharedList.findUnique({ where: { id } })
if (!list || list.userId !== session.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
try {
const body = await request.json()
const parsed = sharedListUpdateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: "Invalid data" }, { status: 400 })
}
const updated = await prisma.sharedList.update({
where: { id },
data: parsed.data,
})
return NextResponse.json(updated)
} catch (error) {
console.error("Update shared list error:", error)
return NextResponse.json(
{ error: "Failed to update shared list" },
{ status: 500 }
)
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { id } = await params
const list = await prisma.sharedList.findUnique({ where: { id } })
if (!list || list.userId !== session.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
await prisma.sharedList.delete({ where: { id } })
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,55 @@
import { NextResponse } from "next/server"
import { randomBytes } from "crypto"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { sharedListCreateSchema } from "@/lib/validators"
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const lists = await prisma.sharedList.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
})
return NextResponse.json({ lists })
}
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const body = await request.json()
const parsed = sharedListCreateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid data", details: parsed.error.flatten().fieldErrors },
{ status: 400 }
)
}
const slug = randomBytes(4).toString("hex")
const list = await prisma.sharedList.create({
data: {
userId: session.user.id,
slug,
...parsed.data,
},
})
return NextResponse.json(list, { status: 201 })
} catch (error) {
console.error("Create shared list error:", error)
return NextResponse.json(
{ error: "Failed to create shared list" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,50 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { uploadImage } from "@/lib/s3"
import { randomUUID } from "crypto"
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const formData = await request.formData()
const file = formData.get("file") as File | null
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 })
}
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/heic"]
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: "Invalid file type. Allowed: JPEG, PNG, WebP, HEIC" },
{ status: 400 }
)
}
const maxSize = 10 * 1024 * 1024 // 10MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: "File too large. Maximum size is 10MB" },
{ status: 400 }
)
}
const buffer = Buffer.from(await file.arrayBuffer())
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1]
const key = `${session.user.id}/${randomUUID()}.${ext}`
const url = await uploadImage(key, buffer, file.type)
return NextResponse.json({ url, key })
} catch (error) {
console.error("Upload error:", error)
return NextResponse.json(
{ error: "Failed to upload file" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,59 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { id } = await params
const item = await prisma.wishlistItem.findUnique({ where: { id } })
if (!item || item.userId !== session.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
await prisma.wishlistItem.delete({ where: { id } })
return NextResponse.json({ success: true })
}
// Promote wishlist item to a drink in the collection
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { id } = await params
const item = await prisma.wishlistItem.findUnique({ where: { id } })
if (!item || item.userId !== session.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
// Create drink from wishlist item
const drink = await prisma.drink.create({
data: {
userId: session.user.id,
name: item.name,
type: item.type,
subType: item.subType,
brewery: item.brewery,
abv: item.abv,
description: item.description,
},
})
// Remove from wishlist
await prisma.wishlistItem.delete({ where: { id } })
return NextResponse.json(drink, { status: 201 })
}

View File

@@ -0,0 +1,51 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { wishlistCreateSchema } from "@/lib/validators"
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const items = await prisma.wishlistItem.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
})
return NextResponse.json({ items })
}
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const body = await request.json()
const parsed = wishlistCreateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid data", details: parsed.error.flatten().fieldErrors },
{ status: 400 }
)
}
const item = await prisma.wishlistItem.create({
data: {
userId: session.user.id,
...parsed.data,
},
})
return NextResponse.json(item, { status: 201 })
} catch (error) {
console.error("Create wishlist item error:", error)
return NextResponse.json(
{ error: "Failed to create wishlist item" },
{ status: 500 }
)
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

BIN
src/app/fonts/GeistVF.woff Normal file

Binary file not shown.

59
src/app/globals.css Normal file
View File

@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 22 90% 50%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 22 90% 50%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 22 90% 50%;
--primary-foreground: 210 40% 98%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 22 90% 50%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

47
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,47 @@
import type { Metadata } from "next"
import localFont from "next/font/local"
import "./globals.css"
import { Providers } from "@/components/providers"
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
})
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
})
import type { Viewport } from "next"
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
viewportFit: "cover",
themeColor: "#ea580c",
}
export const metadata: Metadata = {
title: "DrinkTracker",
description: "Track, rate, and discover your favorite drinks with AI-powered menu scanning",
manifest: "/manifest.json",
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Providers>{children}</Providers>
</body>
</html>
)
}

5
src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function Home() {
redirect("/dashboard")
}

View File

@@ -0,0 +1,171 @@
import { notFound } from "next/navigation"
import { prisma } from "@/lib/prisma"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { Beer, Star } from "lucide-react"
const typeColors: Record<string, string> = {
BEER: "bg-amber-100 text-amber-800",
WINE: "bg-rose-100 text-rose-800",
COCKTAIL: "bg-purple-100 text-purple-800",
SPIRIT: "bg-blue-100 text-blue-800",
OTHER: "bg-gray-100 text-gray-800",
}
export default async function SharedListPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const list = await prisma.sharedList.findUnique({
where: { slug },
include: { user: { select: { name: true } } },
})
if (!list || !list.isPublic) {
notFound()
}
let items: Array<{
id: string
name: string
type: string
subType: string | null
brewery: string | null
abv: number | null
description: string | null
avgRating?: number | null
}> = []
if (list.listType === "wishlist") {
const wishlistItems = await prisma.wishlistItem.findMany({
where: { userId: list.userId },
orderBy: { createdAt: "desc" },
})
items = wishlistItems.map((item) => ({
id: item.id,
name: item.name,
type: item.type,
subType: item.subType,
brewery: item.brewery,
abv: item.abv,
description: item.description,
}))
} else {
const whereClause: Record<string, unknown> = { userId: list.userId }
if (list.drinkIds.length > 0) {
whereClause.id = { in: list.drinkIds }
}
const drinks = await prisma.drink.findMany({
where: whereClause,
include: {
ratings: { select: { score: true } },
},
orderBy: { createdAt: "desc" },
})
items = drinks.map((drink) => {
const avg =
drink.ratings.length > 0
? drink.ratings.reduce((sum, r) => sum + r.score, 0) /
drink.ratings.length
: null
return {
id: drink.id,
name: drink.name,
type: drink.type,
subType: drink.subType,
brewery: drink.brewery,
abv: drink.abv,
description: drink.description,
avgRating: avg,
}
})
}
return (
<div className="min-h-screen bg-background">
<header className="border-b py-4 px-6">
<div className="max-w-4xl mx-auto flex items-center gap-2">
<Beer className="h-6 w-6 text-primary" />
<span className="font-bold text-lg">DrinkTracker</span>
</div>
</header>
<main className="max-w-4xl mx-auto p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold">{list.title}</h1>
{list.description && (
<p className="text-muted-foreground mt-1">{list.description}</p>
)}
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
{list.user.name && <span>Shared by {list.user.name}</span>}
<span>· {items.length} drinks</span>
</div>
</div>
{items.length === 0 ? (
<p className="text-center py-12 text-muted-foreground">
This list is empty.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((item) => (
<Card key={item.id}>
<CardContent className="p-4">
<div className="flex items-center gap-2 flex-wrap mb-1">
<h3 className="font-semibold">{item.name}</h3>
<Badge
variant="secondary"
className={`text-xs ${typeColors[item.type] || ""}`}
>
{item.type}
</Badge>
</div>
<div className="text-sm text-muted-foreground space-y-1">
{item.brewery && <p>{item.brewery}</p>}
<div className="flex items-center gap-2">
{item.subType && <span>{item.subType}</span>}
{item.abv != null && <span>· {item.abv}% ABV</span>}
</div>
{item.avgRating != null && (
<div className="flex items-center gap-1">
{Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={`h-3 w-3 ${
i < Math.round(item.avgRating!)
? "fill-primary text-primary"
: "text-muted-foreground/30"
}`}
/>
))}
<span className="text-xs ml-1">
{item.avgRating.toFixed(1)}
</span>
</div>
)}
</div>
{item.description && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
{item.description}
</p>
)}
</CardContent>
</Card>
))}
</div>
)}
</main>
<footer className="border-t py-4 px-6 mt-12">
<div className="max-w-4xl mx-auto text-center text-sm text-muted-foreground">
Powered by DrinkTracker
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,70 @@
"use client"
import { useState } from "react"
import { Bookmark, Check } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useAddToWishlist } from "@/hooks/use-wishlist"
interface AddToWishlistButtonProps {
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType?: string | null
brewery?: string | null
abv?: number | null
description?: string | null
source?: string
size?: "sm" | "default"
}
export function AddToWishlistButton({
name,
type,
subType,
brewery,
abv,
description,
source = "scan",
size = "sm",
}: AddToWishlistButtonProps) {
const addToWishlist = useAddToWishlist()
const [added, setAdded] = useState(false)
function handleAdd() {
addToWishlist.mutate(
{
name,
type,
subType: subType || undefined,
brewery: brewery || undefined,
abv: abv || undefined,
description: description || undefined,
source,
},
{
onSuccess: () => setAdded(true),
}
)
}
return (
<Button
size={size}
variant={added ? "secondary" : "ghost"}
onClick={handleAdd}
disabled={added || addToWishlist.isPending}
title="Save to Try Later"
>
{added ? (
<>
<Check className="h-3 w-3 mr-1" />
Saved
</>
) : (
<>
<Bookmark className="h-3 w-3 mr-1" />
Later
</>
)}
</Button>
)
}

View File

@@ -0,0 +1,256 @@
"use client"
import { useState } from "react"
import { useMutation } from "@tanstack/react-query"
import { Search, Loader2, Plus, Sparkles, Check, Clock, ArrowLeft, ChevronRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { AddToWishlistButton } from "@/components/drinks/add-to-wishlist-button"
import { useSearchHistory, type SearchHistoryItem } from "@/hooks/use-search-history"
import { cn } from "@/lib/utils"
interface SearchResult {
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType?: string
brewery?: string
abv?: number
description?: string
}
type ItemState = "loading" | "added" | "error"
interface AiDrinkSearchProps {
onAdd: (drink: SearchResult) => Promise<void>
}
const typeColors: Record<string, string> = {
BEER: "bg-amber-100 text-amber-800",
WINE: "bg-purple-100 text-purple-800",
COCKTAIL: "bg-blue-100 text-blue-800",
SPIRIT: "bg-orange-100 text-orange-800",
OTHER: "bg-gray-100 text-gray-800",
}
export function AiDrinkSearch({ onAdd }: AiDrinkSearchProps) {
const [query, setQuery] = useState("")
const [itemStates, setItemStates] = useState<Map<number, ItemState>>(new Map())
const [cachedResults, setCachedResults] = useState<{ query: string; drinks: SearchResult[] } | null>(null)
const history = useSearchHistory()
const search = useMutation({
mutationFn: async (q: string) => {
const res = await fetch("/api/ai/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: q }),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.error || "Search failed")
}
return res.json() as Promise<{ drinks: SearchResult[] }>
},
})
function handleSearch(e: React.FormEvent) {
e.preventDefault()
if (!query.trim()) return
setCachedResults(null)
setItemStates(new Map())
search.mutate(query.trim())
}
function handleViewCached(item: SearchHistoryItem) {
setCachedResults({
query: item.query,
drinks: item.results.drinks as SearchResult[],
})
setItemStates(new Map())
search.reset()
}
function handleBackToHistory() {
setCachedResults(null)
setItemStates(new Map())
search.reset()
}
async function handleAdd(drink: SearchResult, index: number) {
const state = itemStates.get(index)
if (state === "loading" || state === "added") return
setItemStates((prev) => new Map(prev).set(index, "loading"))
try {
await onAdd(drink)
setItemStates((prev) => new Map(prev).set(index, "added"))
} catch {
setItemStates((prev) => new Map(prev).set(index, "error"))
}
}
// Which drinks to display — either from a new AI search or from cached history
const activeDrinks = cachedResults?.drinks ?? search.data?.drinks ?? null
const activeQuery = cachedResults?.query ?? (search.data ? query : null)
const showHistory = !activeDrinks && !search.isPending
return (
<div className="space-y-4">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by name, style, or description... e.g. 'hazy IPA' or 'Two Hearted Ale'"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" disabled={search.isPending || !query.trim()}>
{search.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
</form>
{/* Recent searches - shown when no results are displayed */}
{showHistory && history.data && history.data.searches.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<Clock className="h-4 w-4" />
<span>Recent Searches</span>
</div>
<div className="space-y-1">
{history.data.searches.map((item) => (
<button
key={item.id}
type="button"
onClick={() => handleViewCached(item)}
className="w-full flex items-center justify-between gap-3 px-3 py-2.5 text-sm rounded-lg border bg-background hover:bg-accent transition-colors text-left"
>
<div className="flex items-center gap-2.5 min-w-0">
<Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="truncate font-medium">{item.query}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-muted-foreground">
{item.results.drinks.length} results
</span>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</button>
))}
</div>
</div>
)}
{search.isError && (
<p className="text-sm text-destructive">{search.error.message}</p>
)}
{/* Results header with back button */}
{activeDrinks && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleBackToHistory}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back
</button>
<span className="text-sm text-muted-foreground">·</span>
<span className="text-sm font-medium truncate">
&ldquo;{activeQuery}&rdquo;
</span>
<Badge variant="secondary" className="text-xs shrink-0">
{activeDrinks.length} results
</Badge>
</div>
)}
{activeDrinks && activeDrinks.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No results found. Try a different search term.
</p>
)}
{activeDrinks && activeDrinks.length > 0 && (
<div className="space-y-2">
{activeDrinks.map((drink, i) => {
const state = itemStates.get(i)
return (
<Card key={`${drink.name}-${i}`} className="transition-colors hover:bg-accent/30">
<CardContent className="flex items-start justify-between gap-3 p-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-semibold">{drink.name}</h4>
<Badge
variant="secondary"
className={cn("text-xs", typeColors[drink.type])}
>
{drink.type}
</Badge>
{drink.subType && (
<Badge variant="outline" className="text-xs">
{drink.subType}
</Badge>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
{drink.brewery && <span>{drink.brewery}</span>}
{drink.abv != null && (
<span>{drink.brewery ? "·" : ""} {drink.abv}% ABV</span>
)}
</div>
{drink.description && (
<p className="text-sm text-muted-foreground mt-1">
{drink.description}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Button
size="sm"
variant={state === "added" ? "secondary" : state === "error" ? "destructive" : "outline"}
onClick={() => handleAdd(drink, i)}
disabled={state === "loading" || state === "added"}
>
{state === "loading" ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : state === "added" ? (
<>
<Check className="h-3 w-3 mr-1" />
Added
</>
) : state === "error" ? (
"Retry"
) : (
<>
<Plus className="h-3 w-3 mr-1" />
Add
</>
)}
</Button>
<AddToWishlistButton
name={drink.name}
type={drink.type}
subType={drink.subType}
brewery={drink.brewery}
abv={drink.abv}
description={drink.description}
source="ai_search"
/>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,88 @@
"use client"
import Link from "next/link"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Star } from "lucide-react"
import { cn } from "@/lib/utils"
import type { DrinkListItem } from "@/hooks/use-drinks"
const TYPE_COLORS: Record<string, string> = {
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<string, string> = {
BEER: "Beer",
WINE: "Wine",
COCKTAIL: "Cocktail",
SPIRIT: "Spirit",
OTHER: "Other",
}
interface DrinkCardProps {
drink: DrinkListItem
}
export function DrinkCard({ drink }: DrinkCardProps) {
return (
<Link href={`/drinks/${drink.id}`}>
<Card className="hover:border-primary/50 transition-colors cursor-pointer h-full">
<CardContent className="p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold leading-tight line-clamp-2">
{drink.name}
</h3>
<Badge
className={cn(
"shrink-0 text-[11px]",
TYPE_COLORS[drink.type] || TYPE_COLORS.OTHER
)}
>
{TYPE_LABELS[drink.type] || drink.type}
</Badge>
</div>
{drink.subType && (
<p className="text-sm text-muted-foreground">{drink.subType}</p>
)}
{drink.brewery && (
<p className="text-sm text-muted-foreground truncate">
{drink.brewery}
</p>
)}
<div className="flex items-center justify-between pt-1">
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={cn(
"h-3.5 w-3.5",
drink.avgRating && i < Math.round(drink.avgRating)
? "fill-primary text-primary"
: "fill-none text-muted-foreground/30"
)}
/>
))}
{drink.ratingCount > 0 && (
<span className="text-xs text-muted-foreground ml-1.5">
({drink.avgRating?.toFixed(1)})
</span>
)}
</div>
{drink.abv !== null && drink.abv !== undefined && (
<span className="text-xs text-muted-foreground">
{drink.abv}% ABV
</span>
)}
</div>
</CardContent>
</Card>
</Link>
)
}

View File

@@ -0,0 +1,149 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog"
import { DrinkForm } from "@/components/drinks/drink-form"
import { useUpdateDrink, useDeleteDrink, useDrink } from "@/hooks/use-drinks"
import { Pencil, Trash2 } from "lucide-react"
import type { DrinkCreate } from "@/lib/validators"
interface DrinkDetailActionsProps {
drinkId: string
drinkName: string
}
export function DrinkDetailActions({
drinkId,
drinkName,
}: DrinkDetailActionsProps) {
const router = useRouter()
const [editOpen, setEditOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const { data: drink } = useDrink(editOpen ? drinkId : undefined)
const updateDrink = useUpdateDrink(drinkId)
const deleteDrink = useDeleteDrink(drinkId)
function handleUpdate(formData: DrinkCreate) {
updateDrink.mutate(formData, {
onSuccess: () => {
setEditOpen(false)
router.refresh()
},
})
}
function handleDelete() {
deleteDrink.mutate(undefined, {
onSuccess: () => {
setDeleteOpen(false)
router.push("/drinks")
},
})
}
return (
<>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setEditOpen(true)}
>
<Pencil className="h-3.5 w-3.5 mr-1.5" />
Edit
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
Delete
</Button>
</div>
{/* Edit Dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Drink</DialogTitle>
<DialogDescription>
Update the details for {drinkName}.
</DialogDescription>
</DialogHeader>
{drink ? (
<DrinkForm
initialData={{
name: drink.name,
type: drink.type,
subType: drink.subType || undefined,
brewery: drink.brewery || undefined,
region: drink.region || undefined,
abv: drink.abv || undefined,
description: drink.description || undefined,
}}
onSubmit={handleUpdate}
isSubmitting={updateDrink.isPending}
submitLabel="Update Drink"
/>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
Loading...
</div>
)}
{updateDrink.isError && (
<p className="text-sm text-destructive">
{updateDrink.error.message || "Failed to update drink"}
</p>
)}
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Drink</DialogTitle>
<DialogDescription>
Are you sure you want to delete &ldquo;{drinkName}&rdquo;? This
will also remove all associated ratings. This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleteDrink.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteDrink.isPending}
>
{deleteDrink.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
{deleteDrink.isError && (
<p className="text-sm text-destructive">
{deleteDrink.error.message || "Failed to delete drink"}
</p>
)}
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,74 @@
"use client"
import { Input } from "@/components/ui/input"
import { Select, SelectOption } from "@/components/ui/select"
import { Search } from "lucide-react"
const DRINK_TYPES = [
{ value: "ALL", label: "All Types" },
{ value: "BEER", label: "Beer" },
{ value: "WINE", label: "Wine" },
{ value: "COCKTAIL", label: "Cocktail" },
{ value: "SPIRIT", label: "Spirit" },
{ value: "OTHER", label: "Other" },
]
const SORT_OPTIONS = [
{ value: "recent", label: "Most Recent" },
{ value: "name", label: "Name (A-Z)" },
{ value: "rating", label: "Highest Rated" },
]
interface DrinkFiltersProps {
search: string
type: string
sort: string
onSearchChange: (value: string) => void
onTypeChange: (value: string) => void
onSortChange: (value: string) => void
}
export function DrinkFilters({
search,
type,
sort,
onSearchChange,
onTypeChange,
onSortChange,
}: DrinkFiltersProps) {
return (
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search drinks..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
</div>
<Select
value={type}
onChange={(e) => onTypeChange(e.target.value)}
className="sm:w-[160px]"
>
{DRINK_TYPES.map((t) => (
<SelectOption key={t.value} value={t.value}>
{t.label}
</SelectOption>
))}
</Select>
<Select
value={sort}
onChange={(e) => onSortChange(e.target.value)}
className="sm:w-[170px]"
>
{SORT_OPTIONS.map((s) => (
<SelectOption key={s.value} value={s.value}>
{s.label}
</SelectOption>
))}
</Select>
</div>
)
}

View File

@@ -0,0 +1,175 @@
"use client"
import { useState } 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 type { DrinkCreate } from "@/lib/validators"
const DRINK_TYPES = [
{ value: "BEER", label: "Beer" },
{ value: "WINE", label: "Wine" },
{ value: "COCKTAIL", label: "Cocktail" },
{ value: "SPIRIT", label: "Spirit" },
{ value: "OTHER", label: "Other" },
]
interface DrinkFormProps {
initialData?: Partial<DrinkCreate>
onSubmit: (data: DrinkCreate) => void
isSubmitting?: boolean
submitLabel?: string
}
export function DrinkForm({
initialData,
onSubmit,
isSubmitting = false,
submitLabel = "Save Drink",
}: DrinkFormProps) {
const [name, setName] = useState(initialData?.name || "")
const [type, setType] = useState(initialData?.type || "BEER")
const [subType, setSubType] = useState(initialData?.subType || "")
const [brewery, setBrewery] = useState(initialData?.brewery || "")
const [region, setRegion] = useState(initialData?.region || "")
const [abv, setAbv] = useState(initialData?.abv?.toString() || "")
const [description, setDescription] = useState(
initialData?.description || ""
)
const [errors, setErrors] = useState<Record<string, string>>({})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const newErrors: Record<string, string> = {}
if (!name.trim()) {
newErrors.name = "Name is required"
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors)
return
}
setErrors({})
const data: DrinkCreate = {
name: name.trim(),
type: type as DrinkCreate["type"],
}
if (subType.trim()) data.subType = subType.trim()
if (brewery.trim()) data.brewery = brewery.trim()
if (region.trim()) data.region = region.trim()
if (abv.trim()) {
const abvNum = parseFloat(abv)
if (!isNaN(abvNum) && abvNum >= 0 && abvNum <= 100) {
data.abv = abvNum
}
}
if (description.trim()) data.description = description.trim()
onSubmit(data)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="drink-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="drink-name"
placeholder="e.g., Two Hearted Ale"
value={name}
onChange={(e) => setName(e.target.value)}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="drink-type">
Type <span className="text-destructive">*</span>
</Label>
<Select
id="drink-type"
value={type}
onChange={(e) => setType(e.target.value as typeof type)}
>
{DRINK_TYPES.map((t) => (
<SelectOption key={t.value} value={t.value}>
{t.label}
</SelectOption>
))}
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="drink-subtype">Style / SubType</Label>
<Input
id="drink-subtype"
placeholder="e.g., IPA, Pinot Noir"
value={subType}
onChange={(e) => setSubType(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="drink-brewery">Brewery / Winery</Label>
<Input
id="drink-brewery"
placeholder="e.g., Bell's Brewery"
value={brewery}
onChange={(e) => setBrewery(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="drink-region">Region</Label>
<Input
id="drink-region"
placeholder="e.g., Michigan, Napa Valley"
value={region}
onChange={(e) => setRegion(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="drink-abv">ABV (%)</Label>
<Input
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)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="drink-description">Description</Label>
<Textarea
id="drink-description"
placeholder="Tasting notes, appearance, aroma..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : submitLabel}
</Button>
</form>
)
}

View File

@@ -0,0 +1,43 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { LayoutDashboard, Camera, Wine, Bookmark, Settings } from "lucide-react"
import { cn } from "@/lib/utils"
const navItems = [
{ href: "/dashboard", label: "Home", icon: LayoutDashboard },
{ href: "/scan", label: "Scan", icon: Camera },
{ href: "/drinks", label: "Drinks", icon: Wine },
{ href: "/wishlist", label: "Later", icon: Bookmark },
{ href: "/settings", label: "Settings", icon: Settings },
]
export function BottomNav() {
const pathname = usePathname()
return (
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card border-t safe-area-bottom">
<div className="flex items-center justify-around h-16">
{navItems.map((item) => {
const isActive = pathname.startsWith(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex flex-col items-center gap-1 px-3 py-2 text-xs font-medium transition-colors min-w-[64px]",
isActive
? "text-primary"
: "text-muted-foreground"
)}
>
<item.icon className="h-5 w-5" />
{item.label}
</Link>
)
})}
</div>
</nav>
)
}

View File

@@ -0,0 +1,14 @@
"use client"
import { Beer } from "lucide-react"
export function Header({ title }: { title?: string }) {
return (
<header className="md:hidden sticky top-0 z-40 flex items-center gap-3 h-14 px-4 border-b bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/60">
<Beer className="h-6 w-6 text-primary" />
<h1 className="font-semibold text-lg">
{title || "DrinkTracker"}
</h1>
</header>
)
}

View File

@@ -0,0 +1,91 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { signOut, useSession } from "next-auth/react"
import {
LayoutDashboard,
Camera,
Wine,
Bookmark,
Settings,
LogOut,
Beer,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const navItems = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/scan", label: "Scan Menu", icon: Camera },
{ href: "/drinks", label: "My Drinks", icon: Wine },
{ href: "/wishlist", label: "Try Later", icon: Bookmark },
{ href: "/settings", label: "Settings", icon: Settings },
]
export function Sidebar() {
const pathname = usePathname()
const { data: session } = useSession()
return (
<aside className="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0 border-r bg-card">
<div className="flex flex-col flex-grow pt-5 overflow-y-auto">
<div className="flex items-center gap-2 px-4 mb-8">
<Beer className="h-8 w-8 text-primary" />
<span className="text-xl font-bold">DrinkTracker</span>
</div>
<nav className="flex-1 px-2 space-y-1">
{navItems.map((item) => {
const isActive = pathname.startsWith(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 px-3 py-2 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.icon className="h-5 w-5" />
{item.label}
</Link>
)
})}
</nav>
<div className="p-4 border-t">
{session?.user && (
<div className="flex items-center gap-3 mb-3">
{session.user.image && (
<img
src={session.user.image}
alt=""
className="h-8 w-8 rounded-full"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{session.user.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{session.user.email}
</p>
</div>
</div>
)}
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={() => signOut({ callbackUrl: "/login" })}
>
<LogOut className="h-4 w-4" />
Sign out
</Button>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,27 @@
"use client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { SessionProvider } from "next-auth/react"
import { useState } from "react"
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
})
)
return (
<SessionProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</SessionProvider>
)
}

View File

@@ -0,0 +1,69 @@
"use client"
import { StarRating } from "@/components/ratings/star-rating"
import { Badge } from "@/components/ui/badge"
import { MapPin, RotateCcw, Calendar } from "lucide-react"
import { cn } from "@/lib/utils"
interface RatingDisplayProps {
score: number
notes?: string | null
wouldReorder: boolean
location?: string | null
createdAt: string
className?: string
}
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
}
export function RatingDisplay({
score,
notes,
wouldReorder,
location,
createdAt,
className,
}: RatingDisplayProps) {
return (
<div className={cn("space-y-3", className)}>
{/* Stars and badges */}
<div className="flex items-center justify-between">
<StarRating value={score} readOnly size="sm" />
<div className="flex items-center gap-2">
{wouldReorder && (
<Badge variant="default" className="gap-1">
<RotateCcw className="h-3 w-3" />
Would reorder
</Badge>
)}
</div>
</div>
{/* Notes */}
{notes && (
<p className="text-sm text-foreground leading-relaxed">{notes}</p>
)}
{/* Metadata row */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDate(createdAt)}
</span>
{location && (
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{location}
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,164 @@
"use client"
import { useState } from "react"
import { StarRating } from "@/components/ratings/star-rating"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Input } from "@/components/ui/input"
import { MapPin, RotateCcw } from "lucide-react"
import { cn } from "@/lib/utils"
interface RatingFormData {
score: number
notes?: string
wouldReorder: boolean
location?: string
}
interface RatingFormProps {
initialData?: Partial<RatingFormData>
onSubmit: (data: RatingFormData) => void | Promise<void>
isLoading?: boolean
submitLabel?: string
}
export function RatingForm({
initialData,
onSubmit,
isLoading = false,
submitLabel = "Submit Rating",
}: RatingFormProps) {
const [score, setScore] = useState(initialData?.score || 0)
const [notes, setNotes] = useState(initialData?.notes || "")
const [wouldReorder, setWouldReorder] = useState(
initialData?.wouldReorder ?? false
)
const [location, setLocation] = useState(initialData?.location || "")
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (score < 1 || score > 5) {
setError("Please select a rating between 1 and 5 stars.")
return
}
try {
await onSubmit({
score,
notes: notes.trim() || undefined,
wouldReorder,
location: location.trim() || undefined,
})
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.")
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Star Rating */}
<div className="space-y-2">
<Label>Rating</Label>
<div className="flex items-center gap-3">
<StarRating value={score} onChange={setScore} size="lg" />
{score > 0 && (
<span className="text-sm text-muted-foreground">
{score}/5
</span>
)}
</div>
{error && score === 0 && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
{/* Tasting Notes */}
<div className="space-y-2">
<Label htmlFor="notes">Tasting Notes</Label>
<Textarea
id="notes"
placeholder="What did you think? Describe the flavors, aroma, mouthfeel..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={4}
maxLength={2000}
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground text-right">
{notes.length}/2000
</p>
</div>
{/* Would Reorder Toggle */}
<div className="space-y-2">
<Label>Would you order this again?</Label>
<button
type="button"
role="switch"
aria-checked={wouldReorder}
onClick={() => setWouldReorder(!wouldReorder)}
disabled={isLoading}
className={cn(
"flex items-center gap-3 w-full rounded-lg border p-4 text-left transition-colors",
wouldReorder
? "border-primary bg-primary/5"
: "border-input hover:bg-accent/50"
)}
>
<RotateCcw
className={cn(
"h-5 w-5 shrink-0",
wouldReorder ? "text-primary" : "text-muted-foreground"
)}
/>
<div>
<p className="font-medium text-sm">
{wouldReorder ? "Yes, I would reorder!" : "No, probably not"}
</p>
<p className="text-xs text-muted-foreground">
{wouldReorder
? "This drink is a keeper"
: "Tap to mark as a reorder"}
</p>
</div>
</button>
</div>
{/* Location */}
<div className="space-y-2">
<Label htmlFor="location">Location</Label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="location"
placeholder="Where did you try it? (e.g. Bar name, city)"
value={location}
onChange={(e) => setLocation(e.target.value)}
maxLength={200}
disabled={isLoading}
className="pl-9"
/>
</div>
</div>
{/* Error */}
{error && score > 0 && (
<p className="text-sm text-destructive">{error}</p>
)}
{/* Submit */}
<Button
type="submit"
className="w-full"
size="lg"
disabled={isLoading || score === 0}
>
{isLoading ? "Saving..." : submitLabel}
</Button>
</form>
)
}

View File

@@ -0,0 +1,122 @@
"use client"
import { useState, useCallback } from "react"
import { Star } from "lucide-react"
import { cn } from "@/lib/utils"
interface StarRatingProps {
value: number
onChange?: (value: number) => void
size?: "sm" | "md" | "lg"
readOnly?: boolean
className?: string
}
const sizeClasses = {
sm: "h-4 w-4",
md: "h-6 w-6",
lg: "h-8 w-8",
}
const gapClasses = {
sm: "gap-0.5",
md: "gap-1",
lg: "gap-1.5",
}
export function StarRating({
value,
onChange,
size = "md",
readOnly = false,
className,
}: StarRatingProps) {
const [hoverValue, setHoverValue] = useState(0)
const [, setIsFocused] = useState(false)
const handleClick = useCallback(
(starValue: number) => {
if (!readOnly && onChange) {
onChange(starValue)
}
},
[readOnly, onChange]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (readOnly || !onChange) return
switch (e.key) {
case "ArrowRight":
case "ArrowUp":
e.preventDefault()
onChange(Math.min(5, value + 1))
break
case "ArrowLeft":
case "ArrowDown":
e.preventDefault()
onChange(Math.max(1, value - 1))
break
case "Home":
e.preventDefault()
onChange(1)
break
case "End":
e.preventDefault()
onChange(5)
break
}
},
[readOnly, onChange, value]
)
const displayValue = hoverValue || value
return (
<div
className={cn("inline-flex items-center", gapClasses[size], className)}
role="radiogroup"
aria-label="Star rating"
onMouseLeave={() => !readOnly && setHoverValue(0)}
onKeyDown={handleKeyDown}
>
{Array.from({ length: 5 }, (_, i) => {
const starValue = i + 1
const isFilled = starValue <= displayValue
const isInteractive = !readOnly && !!onChange
return (
<button
key={starValue}
type="button"
role="radio"
aria-checked={starValue === value}
aria-label={`${starValue} star${starValue !== 1 ? "s" : ""}`}
tabIndex={isInteractive ? (starValue === value || (value === 0 && starValue === 1) ? 0 : -1) : -1}
disabled={readOnly}
className={cn(
"transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded-sm",
isInteractive && "cursor-pointer hover:scale-110 transition-transform",
readOnly && "cursor-default"
)}
onClick={() => handleClick(starValue)}
onMouseEnter={() => isInteractive && setHoverValue(starValue)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
>
<Star
className={cn(
sizeClasses[size],
"transition-colors",
isFilled
? "fill-primary text-primary"
: "fill-none text-muted-foreground/40"
)}
/>
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,131 @@
"use client"
import { useRef, useState, useEffect, useCallback } from "react"
import { Camera, SwitchCamera, X } from "lucide-react"
import { Button } from "@/components/ui/button"
interface CameraCaptureProps {
onCapture: (file: File) => void
onClose: () => void
}
export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const streamRef = useRef<MediaStream | null>(null)
const [facingMode, setFacingMode] = useState<"environment" | "user">("environment")
const [error, setError] = useState<string | null>(null)
const startCamera = useCallback(async (facing: "environment" | "user") => {
// Stop existing stream
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop())
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: facing },
audio: false,
})
streamRef.current = stream
if (videoRef.current) {
videoRef.current.srcObject = stream
}
setError(null)
} catch {
setError("Camera access denied. Please allow camera permissions and try again.")
}
}, [])
useEffect(() => {
startCamera(facingMode)
return () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop())
}
}
}, [facingMode, startCamera])
function handleCapture() {
const video = videoRef.current
const canvas = canvasRef.current
if (!video || !canvas) return
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext("2d")
if (!ctx) return
ctx.drawImage(video, 0, 0)
canvas.toBlob(
(blob) => {
if (blob) {
const file = new File([blob], `capture-${Date.now()}.jpg`, { type: "image/jpeg" })
// Stop the camera before calling back
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop())
}
onCapture(file)
}
},
"image/jpeg",
0.9
)
}
function toggleCamera() {
setFacingMode((prev) => (prev === "environment" ? "user" : "environment"))
}
if (error) {
return (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<Camera className="h-12 w-12 text-muted-foreground" />
<p className="text-sm text-destructive">{error}</p>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
)
}
return (
<div className="relative rounded-lg overflow-hidden bg-black">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full max-h-[70vh] object-contain"
/>
<canvas ref={canvasRef} className="hidden" />
{/* Overlay controls */}
<div className="absolute bottom-0 inset-x-0 flex items-center justify-center gap-4 p-4 bg-gradient-to-t from-black/60 to-transparent">
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={onClose}
>
<X className="h-5 w-5" />
</Button>
<Button
size="lg"
className="rounded-full h-16 w-16 bg-white hover:bg-white/90"
onClick={handleCapture}
>
<Camera className="h-6 w-6 text-black" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={toggleCamera}
>
<SwitchCamera className="h-5 w-5" />
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,170 @@
"use client"
import { Star, Plus, ThumbsUp, Sparkles, Loader2, Check } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { AddToWishlistButton } from "@/components/drinks/add-to-wishlist-button"
import { cn } from "@/lib/utils"
interface MenuItemCardProps {
name: string
type: string
subType?: string | null
brewery?: string | null
abv?: number | null
price?: string | null
description?: string | null
matchedDrinkId?: string | null
userRating?: number | null
aiRecommended?: boolean
aiReason?: string | null
onAddToDrinks?: () => void
onQuickRate?: () => void
isAddingToDrinks?: boolean
wasAddedToDrinks?: boolean
}
const typeColors: Record<string, string> = {
BEER: "bg-amber-100 text-amber-800",
WINE: "bg-purple-100 text-purple-800",
COCKTAIL: "bg-blue-100 text-blue-800",
SPIRIT: "bg-orange-100 text-orange-800",
OTHER: "bg-gray-100 text-gray-800",
}
export function MenuItemCard({
name,
type,
subType,
brewery,
abv,
price,
description,
matchedDrinkId,
userRating,
aiRecommended,
aiReason,
onAddToDrinks,
onQuickRate,
isAddingToDrinks,
wasAddedToDrinks,
}: MenuItemCardProps) {
const isMatched = !!matchedDrinkId
return (
<Card
className={cn(
"transition-colors",
isMatched && "border-green-200 bg-green-50/50",
aiRecommended && !isMatched && "border-primary/30 bg-primary/5"
)}
>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold">{name}</h3>
<Badge
variant="secondary"
className={cn("text-xs", typeColors[type])}
>
{type}
</Badge>
{isMatched && (
<Badge variant="outline" className="text-xs text-green-700 border-green-300">
Tried
</Badge>
)}
{aiRecommended && !isMatched && (
<Badge variant="outline" className="text-xs text-primary border-primary/30">
<Sparkles className="h-3 w-3 mr-1" />
Recommended
</Badge>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
{brewery && <span>{brewery}</span>}
{subType && <span>{brewery ? "·" : ""} {subType}</span>}
{abv != null && <span>· {abv}%</span>}
{price && <span>· {price}</span>}
</div>
{description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{description}
</p>
)}
{aiReason && (
<p className="text-sm text-primary mt-2 italic">
<Sparkles className="h-3 w-3 inline mr-1" />
{aiReason}
</p>
)}
{isMatched && userRating && (
<div className="flex items-center gap-1 mt-2">
<span className="text-sm text-muted-foreground">Your rating:</span>
{Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={cn(
"h-4 w-4",
i < userRating
? "fill-primary text-primary"
: "text-muted-foreground/30"
)}
/>
))}
</div>
)}
</div>
<div className="flex flex-col gap-1">
{!isMatched && (
<>
<Button
size="sm"
variant={wasAddedToDrinks ? "secondary" : "outline"}
onClick={onAddToDrinks}
disabled={isAddingToDrinks || wasAddedToDrinks}
>
{isAddingToDrinks ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : wasAddedToDrinks ? (
<>
<Check className="h-3 w-3 mr-1" />
Added
</>
) : (
<>
<Plus className="h-3 w-3 mr-1" />
Add
</>
)}
</Button>
<AddToWishlistButton
name={name}
type={type as "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"}
subType={subType}
brewery={brewery}
abv={abv}
description={description}
source="scan"
/>
</>
)}
{isMatched && (
<Button size="sm" variant="outline" onClick={onQuickRate}>
<ThumbsUp className="h-3 w-3 mr-1" />
Rate
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,172 @@
"use client"
import { useRef, useState, useCallback, useEffect } from "react"
import { Camera, Upload, X, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { CameraCapture } from "@/components/scan/camera-capture"
import { cn } from "@/lib/utils"
interface PhotoUploadProps {
onUpload: (file: File) => void
isUploading?: boolean
accept?: string
}
export function PhotoUpload({
onUpload,
isUploading = false,
accept = "image/jpeg,image/png,image/webp,image/heic",
}: PhotoUploadProps) {
const [preview, setPreview] = useState<string | null>(null)
const [dragActive, setDragActive] = useState(false)
const [showCamera, setShowCamera] = useState(false)
const [hasGetUserMedia, setHasGetUserMedia] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const cameraInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setHasGetUserMedia(
typeof navigator !== "undefined" &&
!!navigator.mediaDevices?.getUserMedia
)
}, [])
const handleFile = useCallback(
(file: File) => {
const reader = new FileReader()
reader.onload = (e) => setPreview(e.target?.result as string)
reader.readAsDataURL(file)
onUpload(file)
},
[onUpload]
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setDragActive(false)
const file = e.dataTransfer.files[0]
if (file && file.type.startsWith("image/")) {
handleFile(file)
}
},
[handleFile]
)
const clearPreview = () => {
setPreview(null)
if (fileInputRef.current) fileInputRef.current.value = ""
if (cameraInputRef.current) cameraInputRef.current.value = ""
}
if (showCamera) {
return (
<CameraCapture
onCapture={(file) => {
setShowCamera(false)
handleFile(file)
}}
onClose={() => setShowCamera(false)}
/>
)
}
if (preview) {
return (
<div className="relative">
<img
src={preview}
alt="Upload preview"
className="w-full rounded-lg max-h-[400px] object-contain bg-muted"
/>
{isUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 rounded-lg">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm font-medium">Analyzing menu...</p>
</div>
</div>
)}
{!isUploading && (
<Button
variant="destructive"
size="icon"
className="absolute top-2 right-2"
onClick={clearPreview}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
)
}
return (
<Card
className={cn(
"border-2 border-dashed transition-colors",
dragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25"
)}
onDragOver={(e) => {
e.preventDefault()
setDragActive(true)
}}
onDragLeave={() => setDragActive(false)}
onDrop={handleDrop}
>
<CardContent className="flex flex-col items-center gap-4 py-12">
<div className="flex gap-3">
<Button
size="lg"
onClick={() => {
if (hasGetUserMedia) {
setShowCamera(true)
} else {
cameraInputRef.current?.click()
}
}}
className="gap-2"
>
<Camera className="h-5 w-5" />
Take Photo
</Button>
<Button
size="lg"
variant="outline"
onClick={() => fileInputRef.current?.click()}
className="gap-2"
>
<Upload className="h-5 w-5" />
Upload
</Button>
</div>
<p className="text-sm text-muted-foreground text-center">
Take a photo of a menu or label, or drag and drop an image here
</p>
<input
ref={cameraInputRef}
type="file"
accept={accept}
capture="environment"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
}}
/>
<input
ref={fileInputRef}
type="file"
accept={accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
}}
/>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,119 @@
"use client"
import { useRef, useState } from "react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Download, Upload, Database, Loader2 } from "lucide-react"
import { useExportBackup } from "@/hooks/use-backup"
import { RestoreDialog } from "@/components/settings/restore-dialog"
export function BackupRestore() {
const fileInputRef = useRef<HTMLInputElement>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const exportBackup = useExportBackup()
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
// Validate file type
if (!file.name.endsWith(".csv") && file.type !== "text/csv") {
alert("Please select a CSV file.")
return
}
setSelectedFile(file)
setDialogOpen(true)
// Reset input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Backup & Restore
</CardTitle>
<CardDescription>
Export your data as a CSV file or restore from a previous backup
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Backup section */}
<div className="space-y-2">
<h4 className="text-sm font-medium">Backup</h4>
<p className="text-xs text-muted-foreground">
Download a CSV backup of all your drinks, ratings, wishlist items,
preferences, and shared lists.
</p>
<Button
variant="outline"
onClick={() => exportBackup.mutate()}
disabled={exportBackup.isPending}
>
{exportBackup.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Exporting...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
Download Backup
</>
)}
</Button>
{exportBackup.isError && (
<p className="text-xs text-destructive">
{exportBackup.error.message}
</p>
)}
</div>
<Separator />
{/* Restore section */}
<div className="space-y-2">
<h4 className="text-sm font-medium">Restore</h4>
<p className="text-xs text-muted-foreground">
Upload a previously exported CSV backup file to restore your data.
</p>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-4 w-4 mr-2" />
Upload CSV File
</Button>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={handleFileSelect}
/>
</div>
</CardContent>
</Card>
<RestoreDialog
file={selectedFile}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
)
}

View File

@@ -0,0 +1,240 @@
"use client"
import { useState } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Select, SelectOption } from "@/components/ui/select"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Loader2, AlertTriangle, CheckCircle } from "lucide-react"
import { useRestoreBackup } from "@/hooks/use-backup"
import type { RestoreSummary } from "@/lib/backup"
interface RestoreDialogProps {
file: File | null
open: boolean
onOpenChange: (open: boolean) => void
}
const MODE_DESCRIPTIONS: Record<string, string> = {
"merge-skip":
"Existing records matching by name and type will be kept unchanged. New records from the backup will be added.",
"merge-update":
"Existing records matching by name and type will be updated with data from the backup. New records will be added.",
replace:
"All your current data will be permanently deleted and replaced with data from this backup file. This cannot be undone.",
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function RestoreDialog({
file,
open,
onOpenChange,
}: RestoreDialogProps) {
const [mode, setMode] = useState<string>("merge-skip")
const [summary, setSummary] = useState<RestoreSummary | null>(null)
const restore = useRestoreBackup()
function handleRestore() {
if (!file) return
restore.mutate(
{ file, mode },
{
onSuccess: (data) => {
setSummary(data.summary)
},
}
)
}
function handleClose() {
if (restore.isPending) return
onOpenChange(false)
// Reset state after close animation
setTimeout(() => {
setSummary(null)
setMode("merge-skip")
restore.reset()
}, 200)
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[480px]">
{summary ? (
// ─── Success Summary ─────────────────────────
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
Restore Complete
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<SummaryRow
label="Drinks"
data={summary.drinks}
/>
<SummaryRow
label="Ratings"
data={summary.ratings}
/>
<SummaryRow
label="Wishlist"
data={summary.wishlist}
/>
<div className="flex items-center justify-between py-1">
<span className="text-muted-foreground">Preferences</span>
<Badge variant={summary.preferences.restored ? "default" : "secondary"}>
{summary.preferences.restored ? "Restored" : "Unchanged"}
</Badge>
</div>
<SummaryRow
label="Shared Lists"
data={summary.sharedLists}
/>
</div>
<DialogFooter>
<Button onClick={handleClose}>Done</Button>
</DialogFooter>
</>
) : (
// ─── Restore Form ───────────────────────────
<>
<DialogHeader>
<DialogTitle>Restore from Backup</DialogTitle>
<DialogDescription>
Choose how to handle existing data
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* File info */}
{file && (
<div className="rounded-md border p-3 text-sm">
<p className="font-medium truncate">{file.name}</p>
<p className="text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
)}
{/* Mode selector */}
<div className="space-y-2">
<Label>Restore Mode</Label>
<Select
value={mode}
onChange={(e) => setMode(e.target.value)}
disabled={restore.isPending}
>
<SelectOption value="merge-skip">
Merge (skip duplicates)
</SelectOption>
<SelectOption value="merge-update">
Merge (update duplicates)
</SelectOption>
<SelectOption value="replace">Replace all</SelectOption>
</Select>
<p className="text-xs text-muted-foreground">
{MODE_DESCRIPTIONS[mode]}
</p>
</div>
{/* Warning for replace mode */}
{mode === "replace" && (
<div className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>
This will permanently delete all your current drinks,
ratings, wishlist items, preferences, and shared lists.
</span>
</div>
)}
{/* Error */}
{restore.isError && (
<p className="text-sm text-destructive">
{restore.error.message}
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={restore.isPending}
>
Cancel
</Button>
<Button
variant={mode === "replace" ? "destructive" : "default"}
onClick={handleRestore}
disabled={restore.isPending || !file}
>
{restore.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Restoring...
</>
) : mode === "replace" ? (
"I understand, Replace All"
) : (
"Restore"
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}
function SummaryRow({
label,
data,
}: {
label: string
data: { created: number; updated: number; skipped: number }
}) {
return (
<div className="flex items-center justify-between py-1">
<span className="text-muted-foreground">{label}</span>
<div className="flex items-center gap-2">
{data.created > 0 && (
<Badge variant="default" className="text-xs">
{data.created} created
</Badge>
)}
{data.updated > 0 && (
<Badge variant="secondary" className="text-xs">
{data.updated} updated
</Badge>
)}
{data.skipped > 0 && (
<Badge variant="outline" className="text-xs">
{data.skipped} skipped
</Badge>
)}
{data.created === 0 && data.updated === 0 && data.skipped === 0 && (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
"use client"
import { useState } from "react"
import { Share2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ShareDialog } from "./share-dialog"
export function ShareButton() {
const [open, setOpen] = useState(false)
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
<Share2 className="h-4 w-4 mr-2" />
Share
</Button>
<ShareDialog open={open} onOpenChange={setOpen} />
</>
)
}

View File

@@ -0,0 +1,189 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Copy, Check, Trash2, Link2, Loader2 } from "lucide-react"
interface SharedList {
id: string
slug: string
title: string
description: string | null
listType: string
isPublic: boolean
createdAt: string
}
interface ShareDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
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`)
}
return res.json()
}
export function ShareDialog({ open, onOpenChange }: ShareDialogProps) {
const queryClient = useQueryClient()
const [title, setTitle] = useState("")
const [listType, setListType] = useState("collection")
const [copiedSlug, setCopiedSlug] = useState<string | null>(null)
const { data, isLoading } = useQuery<{ lists: SharedList[] }>({
queryKey: ["shared-lists"],
queryFn: () => fetchWithError("/api/shared-lists"),
enabled: open,
})
const createList = useMutation({
mutationFn: (data: { title: string; listType: string }) =>
fetchWithError("/api/shared-lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["shared-lists"] })
setTitle("")
},
})
const deleteList = useMutation({
mutationFn: (id: string) =>
fetchWithError(`/api/shared-lists/${id}`, { method: "DELETE" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["shared-lists"] })
},
})
function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!title.trim()) return
createList.mutate({ title: title.trim(), listType })
}
function copyLink(slug: string) {
const url = `${window.location.origin}/share/${slug}`
navigator.clipboard.writeText(url)
setCopiedSlug(slug)
setTimeout(() => setCopiedSlug(null), 2000)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Share Your Drinks</DialogTitle>
<DialogDescription>
Create shareable links to your drink collection or wishlist.
</DialogDescription>
</DialogHeader>
{/* Existing shared lists */}
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : data?.lists && data.lists.length > 0 ? (
<div className="space-y-2">
<p className="text-sm font-medium">Your shared links</p>
{data.lists.map((list) => (
<div
key={list.id}
className="flex items-center justify-between gap-2 p-3 rounded-lg border"
>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{list.title}</p>
<p className="text-xs text-muted-foreground">
{list.listType} · /share/{list.slug}
</p>
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
onClick={() => copyLink(list.slug)}
>
{copiedSlug === list.slug ? (
<>
<Check className="h-3 w-3 mr-1" />
Copied
</>
) : (
<>
<Copy className="h-3 w-3 mr-1" />
Copy
</>
)}
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => deleteList.mutate(list.id)}
disabled={deleteList.isPending}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
) : null}
{/* Create new shared list */}
<form onSubmit={handleCreate} className="space-y-3 pt-2 border-t">
<p className="text-sm font-medium">Create new shared link</p>
<div>
<Label htmlFor="share-title">Title</Label>
<Input
id="share-title"
placeholder="My Top Beers"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div>
<Label htmlFor="share-type">What to share</Label>
<select
id="share-type"
value={listType}
onChange={(e) => setListType(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="collection">My Full Collection</option>
<option value="wishlist">My Wishlist</option>
</select>
</div>
<Button
type="submit"
className="w-full"
disabled={createList.isPending || !title.trim()}
>
{createList.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Link2 className="h-4 w-4 mr-2" />
)}
Create Share Link
</Button>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,35 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { cva, type VariantProps } from "class-variance-authority"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { cva, type VariantProps } from "class-variance-authority"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,203 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { X } from "lucide-react"
interface DialogContextValue {
open: boolean
onOpenChange: (open: boolean) => void
}
const DialogContext = React.createContext<DialogContextValue>({
open: false,
onOpenChange: () => {},
})
interface DialogProps {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}
function Dialog({ open = false, onOpenChange, children }: DialogProps) {
const [internalOpen, setInternalOpen] = React.useState(open)
const isControlled = onOpenChange !== undefined
const isOpen = isControlled ? open : internalOpen
const setOpen = isControlled ? onOpenChange : setInternalOpen
return (
<DialogContext.Provider value={{ open: isOpen, onOpenChange: setOpen }}>
{children}
</DialogContext.Provider>
)
}
interface DialogTriggerProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean
}
const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
({ className, onClick, ...props }, ref) => {
const { onOpenChange } = React.useContext(DialogContext)
return (
<button
ref={ref}
className={cn(className)}
onClick={(e) => {
onOpenChange(true)
onClick?.(e)
}}
{...props}
/>
)
}
)
DialogTrigger.displayName = "DialogTrigger"
interface DialogPortalProps {
children: React.ReactNode
}
function DialogPortal({ children }: DialogPortalProps) {
return <>{children}</>
}
const DialogOverlay = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { onOpenChange } = React.useContext(DialogContext)
return (
<div
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
onClick={() => onOpenChange(false)}
{...props}
/>
)
})
DialogOverlay.displayName = "DialogOverlay"
const DialogContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => {
const { open, onOpenChange } = React.useContext(DialogContext)
React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onOpenChange(false)
}
}
if (open) {
document.addEventListener("keydown", handleEscape)
document.body.style.overflow = "hidden"
}
return () => {
document.removeEventListener("keydown", handleEscape)
document.body.style.overflow = ""
}
}, [open, onOpenChange])
if (!open) return null
return (
<DialogPortal>
<DialogOverlay />
<div
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg",
className
)}
{...props}
>
{children}
<button
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</div>
</DialogPortal>
)
})
DialogContent.displayName = "DialogContent"
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h2
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = "DialogTitle"
const DialogDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = "DialogDescription"
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,211 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
// --- Dropdown Context ---
interface DropdownContextValue {
open: boolean
onOpenChange: (open: boolean) => void
}
const DropdownContext = React.createContext<DropdownContextValue>({
open: false,
onOpenChange: () => {},
})
// --- DropdownMenu ---
interface DropdownMenuProps {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}
function DropdownMenu({
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
children,
}: DropdownMenuProps) {
const [internalOpen, setInternalOpen] = React.useState(false)
const isControlled = controlledOnOpenChange !== undefined
const open = isControlled ? (controlledOpen ?? false) : internalOpen
const onOpenChange = isControlled ? controlledOnOpenChange : setInternalOpen
return (
<DropdownContext.Provider value={{ open, onOpenChange }}>
<div className="relative inline-block text-left">{children}</div>
</DropdownContext.Provider>
)
}
// --- DropdownMenuTrigger ---
interface DropdownMenuTriggerProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean
}
const DropdownMenuTrigger = React.forwardRef<
HTMLButtonElement,
DropdownMenuTriggerProps
>(({ className, onClick, ...props }, ref) => {
const { open, onOpenChange } = React.useContext(DropdownContext)
return (
<button
ref={ref}
className={cn(className)}
aria-expanded={open}
aria-haspopup="true"
onClick={(e) => {
onOpenChange(!open)
onClick?.(e)
}}
{...props}
/>
)
})
DropdownMenuTrigger.displayName = "DropdownMenuTrigger"
// --- DropdownMenuContent ---
interface DropdownMenuContentProps
extends React.HTMLAttributes<HTMLDivElement> {
align?: "start" | "center" | "end"
sideOffset?: number
}
const DropdownMenuContent = React.forwardRef<
HTMLDivElement,
DropdownMenuContentProps
>(({ className, align = "center", children, ...props }, ref) => {
const { open, onOpenChange } = React.useContext(DropdownContext)
const contentRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (!open) return
const handleClickOutside = (e: MouseEvent) => {
if (
contentRef.current &&
!contentRef.current.contains(e.target as Node) &&
!(e.target as HTMLElement).closest("[aria-haspopup]")
) {
onOpenChange(false)
}
}
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onOpenChange(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
document.addEventListener("keydown", handleEscape)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
document.removeEventListener("keydown", handleEscape)
}
}, [open, onOpenChange])
if (!open) return null
return (
<div
ref={(node) => {
(contentRef as React.MutableRefObject<HTMLDivElement | null>).current =
node
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
}}
className={cn(
"absolute z-50 mt-1 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
align === "start" && "left-0",
align === "center" && "left-1/2 -translate-x-1/2",
align === "end" && "right-0",
className
)}
{...props}
>
{children}
</div>
)
})
DropdownMenuContent.displayName = "DropdownMenuContent"
// --- DropdownMenuItem ---
interface DropdownMenuItemProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
inset?: boolean
}
const DropdownMenuItem = React.forwardRef<
HTMLButtonElement,
DropdownMenuItemProps
>(({ className, inset, onClick, ...props }, ref) => {
const { onOpenChange } = React.useContext(DropdownContext)
return (
<button
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50",
inset && "pl-8",
className
)}
onClick={(e) => {
onClick?.(e)
onOpenChange(false)
}}
{...props}
/>
)
})
DropdownMenuItem.displayName = "DropdownMenuItem"
// --- DropdownMenuSeparator ---
const DropdownMenuSeparator = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = "DropdownMenuSeparator"
// --- DropdownMenuLabel ---
const DropdownMenuLabel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<div
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = "DropdownMenuLabel"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
}

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { cva, type VariantProps } from "class-variance-authority"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
export interface LabelProps
extends React.LabelHTMLAttributes<HTMLLabelElement>,
VariantProps<typeof labelVariants> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
)
)
Label.displayName = "Label"
export { Label }

View File

@@ -0,0 +1,49 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { ChevronDown } from "lucide-react"
export interface SelectProps
extends React.SelectHTMLAttributes<HTMLSelectElement> {}
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, children, ...props }, ref) => {
return (
<div className="relative">
<select
className={cn(
"flex h-10 w-full appearance-none rounded-md border border-input bg-background px-3 py-2 pr-8 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
>
{children}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-3 h-4 w-4 opacity-50" />
</div>
)
}
)
Select.displayName = "Select"
export interface SelectOptionProps
extends React.OptionHTMLAttributes<HTMLOptionElement> {}
const SelectOption = React.forwardRef<HTMLOptionElement, SelectOptionProps>(
({ className, ...props }, ref) => {
return <option ref={ref} className={cn(className)} {...props} />
}
)
SelectOption.displayName = "SelectOption"
export interface SelectGroupProps
extends React.OptgroupHTMLAttributes<HTMLOptGroupElement> {}
const SelectGroup = React.forwardRef<HTMLOptGroupElement, SelectGroupProps>(
({ className, ...props }, ref) => {
return <optgroup ref={ref} className={cn(className)} {...props} />
}
)
SelectGroup.displayName = "SelectGroup"
export { Select, SelectOption, SelectGroup }

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical"
decorative?: boolean
}
const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<div
ref={ref}
role={decorative ? "none" : "separator"}
aria-orientation={decorative ? undefined : orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = "Separator"
export { Separator }

View File

@@ -0,0 +1,16 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

232
src/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,232 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
success:
"border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100",
},
},
defaultVariants: {
variant: "default",
},
}
)
// --- Toast Types ---
type ToastVariant = VariantProps<typeof toastVariants>["variant"]
interface ToastMessage {
id: string
title?: string
description?: string
variant?: ToastVariant
duration?: number
}
interface ToastState {
toasts: ToastMessage[]
}
type ToastAction =
| { type: "ADD_TOAST"; toast: ToastMessage }
| { type: "REMOVE_TOAST"; id: string }
// --- Toast Reducer ---
function toastReducer(state: ToastState, action: ToastAction): ToastState {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [...state.toasts, action.toast],
}
case "REMOVE_TOAST":
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.id),
}
default:
return state
}
}
// --- Toast Context ---
interface ToastContextValue {
toasts: ToastMessage[]
toast: (props: Omit<ToastMessage, "id">) => void
dismiss: (id: string) => void
}
const ToastContext = React.createContext<ToastContextValue | undefined>(
undefined
)
let toastCount = 0
function genId() {
toastCount = (toastCount + 1) % Number.MAX_SAFE_INTEGER
return toastCount.toString()
}
// --- Toast Provider ---
interface ToastProviderProps {
children: React.ReactNode
}
function ToastProvider({ children }: ToastProviderProps) {
const [state, dispatch] = React.useReducer(toastReducer, { toasts: [] })
const toast = React.useCallback(
(props: Omit<ToastMessage, "id">) => {
const id = genId()
const duration = props.duration ?? 5000
dispatch({ type: "ADD_TOAST", toast: { ...props, id } })
if (duration > 0) {
setTimeout(() => {
dispatch({ type: "REMOVE_TOAST", id })
}, duration)
}
},
[]
)
const dismiss = React.useCallback((id: string) => {
dispatch({ type: "REMOVE_TOAST", id })
}, [])
return (
<ToastContext.Provider value={{ toasts: state.toasts, toast, dismiss }}>
{children}
<ToastViewport toasts={state.toasts} dismiss={dismiss} />
</ToastContext.Provider>
)
}
// --- useToast Hook ---
function useToast() {
const context = React.useContext(ToastContext)
if (!context) {
throw new Error("useToast must be used within a ToastProvider")
}
return context
}
// --- Toast UI Components ---
interface ToastViewportProps {
toasts: ToastMessage[]
dismiss: (id: string) => void
}
function ToastViewport({ toasts, dismiss }: ToastViewportProps) {
if (toasts.length === 0) return null
return (
<div className="fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:max-w-[420px]">
{toasts.map((t) => (
<Toast key={t.id} variant={t.variant}>
<div className="grid gap-1">
{t.title && <ToastTitle>{t.title}</ToastTitle>}
{t.description && (
<ToastDescription>{t.description}</ToastDescription>
)}
</div>
<ToastClose onClick={() => dismiss(t.id)} />
</Toast>
))}
</div>
)
}
// --- Toast Primitives ---
interface ToastProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof toastVariants> {}
const Toast = React.forwardRef<HTMLDivElement, ToastProps>(
({ className, variant, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
}
)
Toast.displayName = "Toast"
const ToastTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = "ToastTitle"
const ToastDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = "ToastDescription"
interface ToastCloseProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
const ToastClose = React.forwardRef<HTMLButtonElement, ToastCloseProps>(
({ className, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100",
className
)}
{...props}
>
<X className="h-4 w-4" />
</button>
)
}
)
ToastClose.displayName = "ToastClose"
export {
ToastProvider,
useToast,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
toastVariants,
}
export type { ToastMessage, ToastVariant }

68
src/hooks/use-backup.ts Normal file
View File

@@ -0,0 +1,68 @@
"use client"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import type { RestoreSummary } from "@/lib/backup"
interface RestoreResponse {
success: boolean
summary: RestoreSummary
}
export function useExportBackup() {
return useMutation({
mutationFn: async () => {
const res = await fetch("/api/settings/backup")
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || "Failed to export backup")
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const disposition = res.headers.get("Content-Disposition")
const filename =
disposition?.match(/filename="(.+)"/)?.[1] ||
`drinktracker-backup-${new Date().toISOString().split("T")[0]}.csv`
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
},
})
}
export function useRestoreBackup() {
const queryClient = useQueryClient()
return useMutation<RestoreResponse, Error, { file: File; mode: string }>({
mutationFn: async ({ file, mode }) => {
const formData = new FormData()
formData.append("file", file)
formData.append("mode", mode)
const res = await fetch("/api/settings/restore", {
method: "POST",
body: formData,
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || "Failed to restore backup")
}
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["drinks"] })
queryClient.invalidateQueries({ queryKey: ["drink"] })
queryClient.invalidateQueries({ queryKey: ["wishlist"] })
queryClient.invalidateQueries({ queryKey: ["preferences"] })
queryClient.invalidateQueries({ queryKey: ["shared-lists"] })
queryClient.invalidateQueries({ queryKey: ["search-history"] })
},
})
}

137
src/hooks/use-drinks.ts Normal file
View File

@@ -0,0 +1,137 @@
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import type { DrinkCreate, DrinkUpdate } from "@/lib/validators"
export interface DrinkFilters {
search?: string
type?: string
sort?: string
page?: number
limit?: number
}
export interface DrinkListItem {
id: string
userId: string
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType: string | null
brewery: string | null
region: string | null
abv: number | null
description: string | null
imageUrl: string | null
createdAt: string
updatedAt: string
avgRating: number | null
ratingCount: number
}
export interface DrinkRating {
id: string
userId: string
drinkId: string
score: number
notes: string | null
wouldReorder: boolean
location: string | null
createdAt: string
updatedAt: string
}
export interface DrinkDetail extends DrinkListItem {
ratings: DrinkRating[]
}
export interface DrinksResponse {
drinks: DrinkListItem[]
pagination: {
page: number
limit: number
total: number
totalPages: 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 useDrinks(filters: DrinkFilters = {}) {
const params = new URLSearchParams()
if (filters.search) params.set("search", filters.search)
if (filters.type && filters.type !== "ALL") params.set("type", filters.type)
if (filters.sort) params.set("sort", filters.sort)
if (filters.page) params.set("page", String(filters.page))
if (filters.limit) params.set("limit", String(filters.limit))
const queryString = params.toString()
const url = `/api/drinks${queryString ? `?${queryString}` : ""}`
return useQuery<DrinksResponse>({
queryKey: ["drinks", filters],
queryFn: () => fetchWithError(url),
})
}
export function useDrink(id: string | undefined) {
return useQuery<DrinkDetail>({
queryKey: ["drink", id],
queryFn: () => fetchWithError(`/api/drinks/${id}`),
enabled: !!id,
})
}
export function useCreateDrink() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: DrinkCreate) =>
fetchWithError("/api/drinks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["drinks"] })
},
})
}
export function useUpdateDrink(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: DrinkUpdate) =>
fetchWithError(`/api/drinks/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["drinks"] })
queryClient.invalidateQueries({ queryKey: ["drink", id] })
},
})
}
export function useDeleteDrink(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () =>
fetchWithError(`/api/drinks/${id}`, {
method: "DELETE",
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["drinks"] })
queryClient.removeQueries({ queryKey: ["drink", id] })
},
})
}

151
src/hooks/use-ratings.ts Normal file
View File

@@ -0,0 +1,151 @@
"use client"
import {
useQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query"
import type { RatingCreate, RatingUpdate } from "@/lib/validators"
interface RatingDrink {
id: string
name: string
type: string
subType: string | null
brewery: string | null
imageUrl: string | null
}
export interface Rating {
id: string
userId: string
drinkId: string
score: number
notes: string | null
wouldReorder: boolean
location: string | null
createdAt: string
updatedAt: string
drink: RatingDrink
}
interface RatingsResponse {
ratings: Rating[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}
interface UseRatingsOptions {
drinkId?: string
page?: number
limit?: number
sort?: "recent" | "score-high" | "score-low"
}
async function fetchRatings(options: UseRatingsOptions): Promise<RatingsResponse> {
const params = new URLSearchParams()
if (options.drinkId) params.set("drinkId", options.drinkId)
if (options.page) params.set("page", String(options.page))
if (options.limit) params.set("limit", String(options.limit))
if (options.sort) params.set("sort", options.sort)
const res = await fetch(`/api/ratings?${params.toString()}`)
if (!res.ok) {
const error = await res.json().catch(() => ({ error: "Request failed" }))
throw new Error(error.error || "Failed to fetch ratings")
}
return res.json()
}
export function useRatings(options: UseRatingsOptions = {}) {
return useQuery({
queryKey: ["ratings", options],
queryFn: () => fetchRatings(options),
})
}
export function useRating(id: string) {
return useQuery({
queryKey: ["ratings", id],
queryFn: async () => {
const res = await fetch(`/api/ratings/${id}`)
if (!res.ok) {
const error = await res.json().catch(() => ({ error: "Request failed" }))
throw new Error(error.error || "Failed to fetch rating")
}
return res.json() as Promise<Rating>
},
enabled: !!id,
})
}
export function useCreateRating() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: RatingCreate) => {
const res = await fetch("/api/ratings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
if (!res.ok) {
const error = await res.json().catch(() => ({ error: "Request failed" }))
throw new Error(error.error || "Failed to create rating")
}
return res.json() as Promise<Rating>
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ratings"] })
queryClient.invalidateQueries({ queryKey: ["drinks"] })
},
})
}
export function useUpdateRating(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: RatingUpdate) => {
const res = await fetch(`/api/ratings/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
if (!res.ok) {
const error = await res.json().catch(() => ({ error: "Request failed" }))
throw new Error(error.error || "Failed to update rating")
}
return res.json() as Promise<Rating>
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ratings"] })
queryClient.invalidateQueries({ queryKey: ["drinks"] })
},
})
}
export function useDeleteRating(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () => {
const res = await fetch(`/api/ratings/${id}`, {
method: "DELETE",
})
if (!res.ok) {
const error = await res.json().catch(() => ({ error: "Request failed" }))
throw new Error(error.error || "Failed to delete rating")
}
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ratings"] })
queryClient.invalidateQueries({ queryKey: ["drinks"] })
},
})
}

109
src/hooks/use-scan.ts Normal file
View File

@@ -0,0 +1,109 @@
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
interface ScanResult {
id: string
status: "UPLOADING" | "PROCESSING" | "COMPLETED" | "FAILED"
imageUrl: string
errorMessage?: string
items: Array<{
id: string
name: string
type: string
subType?: string
brewery?: string
abv?: number
price?: string
description?: string
matchedDrinkId?: string
userRating?: number
aiRecommended: boolean
aiReason?: string
matchedDrink?: {
id: string
name: string
ratings: Array<{ score: number }>
}
}>
}
export function useScan(id: string | undefined) {
return useQuery<ScanResult>({
queryKey: ["scan", id],
queryFn: async () => {
const res = await fetch(`/api/scan/${id}`)
if (!res.ok) throw new Error("Failed to fetch scan")
return res.json()
},
enabled: !!id,
refetchInterval: (query) => {
const data = query.state.data
if (data?.status === "PROCESSING" || data?.status === "UPLOADING") {
return 2000 // Poll every 2s while processing
}
return false
},
})
}
export function useScans() {
return useQuery({
queryKey: ["scans"],
queryFn: async () => {
const res = await fetch("/api/scan")
if (!res.ok) throw new Error("Failed to fetch scans")
return res.json()
},
})
}
export function useCreateScan() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (file: File) => {
const formData = new FormData()
formData.append("file", file)
const res = await fetch("/api/scan", {
method: "POST",
body: formData,
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || "Failed to create scan")
}
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["scans"] })
},
})
}
export function useAddDrinkFromScan() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (item: {
name: string
type: string
subType?: string
brewery?: string
abv?: number
description?: string
}) => {
const res = await fetch("/api/drinks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(item),
})
if (!res.ok) throw new Error("Failed to add drink")
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["drinks"] })
queryClient.invalidateQueries({ queryKey: ["scans"] })
},
})
}

View File

@@ -0,0 +1,35 @@
"use client"
import { useQuery } from "@tanstack/react-query"
export interface SearchHistoryDrink {
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType?: string
brewery?: string
abv?: number
description?: string
}
export interface SearchHistoryItem {
id: string
query: string
provider: string
results: { drinks: SearchHistoryDrink[] }
createdAt: string
}
interface SearchHistoryResponse {
searches: SearchHistoryItem[]
}
export function useSearchHistory() {
return useQuery<SearchHistoryResponse>({
queryKey: ["search-history"],
queryFn: async () => {
const res = await fetch("/api/ai/search/history")
if (!res.ok) throw new Error("Failed to load search history")
return res.json()
},
})
}

76
src/hooks/use-wishlist.ts Normal file
View File

@@ -0,0 +1,76 @@
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import type { WishlistCreate } from "@/lib/validators"
export interface WishlistItem {
id: string
userId: string
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType: string | null
brewery: string | null
abv: number | null
description: string | null
notes: string | null
source: string | null
createdAt: string
updatedAt: string
}
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 useWishlist() {
return useQuery<{ items: WishlistItem[] }>({
queryKey: ["wishlist"],
queryFn: () => fetchWithError("/api/wishlist"),
})
}
export function useAddToWishlist() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: WishlistCreate) =>
fetchWithError("/api/wishlist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wishlist"] })
},
})
}
export function useRemoveFromWishlist() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
fetchWithError(`/api/wishlist/${id}`, { method: "DELETE" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wishlist"] })
},
})
}
export function usePromoteWishlistItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
fetchWithError(`/api/wishlist/${id}`, { method: "POST" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wishlist"] })
queryClient.invalidateQueries({ queryKey: ["drinks"] })
},
})
}

209
src/lib/ai/base-provider.ts Normal file
View File

@@ -0,0 +1,209 @@
import type {
AIProvider,
ExtractedMenuItem,
MenuExtractionResult,
DrinkRecommendation,
RecommendationResult,
LabelExtractionResult,
DrinkSearchResult,
UserDrinkSummary,
UserPreferenceSummary,
} from "./types"
import {
MENU_EXTRACTION_PROMPT,
LABEL_EXTRACTION_PROMPT,
DRINK_SEARCH_PROMPT,
buildRecommendationPrompt,
} from "./prompts"
export abstract class BaseAIProvider implements AIProvider {
abstract name: string
abstract sendVisionRequest(
systemPrompt: string,
imageBase64: string,
mimeType: string
): Promise<string>
abstract sendTextRequest(
systemPrompt: string,
userMessage: string
): Promise<string>
async extractMenuItems(
imageBase64: string,
mimeType: string
): Promise<MenuExtractionResult> {
const rawResponse = await this.sendVisionRequest(
MENU_EXTRACTION_PROMPT,
imageBase64,
mimeType
)
try {
const parsed = this.parseJsonFromResponse(rawResponse)
const items: ExtractedMenuItem[] = Array.isArray(parsed) ? parsed : []
const validatedItems = items.map((item) => ({
name: String(item.name || "Unknown"),
type: this.validateDrinkType(item.type),
...(item.subType && { subType: String(item.subType) }),
...(item.brewery && { brewery: String(item.brewery) }),
...(item.abv != null && { abv: Number(item.abv) }),
...(item.price && { price: String(item.price) }),
...(item.description && { description: String(item.description) }),
}))
return { items: validatedItems, rawResponse }
} catch (error) {
console.error("Failed to parse menu extraction response:", error)
return { items: [], rawResponse }
}
}
async recommendDrinks(
extractedItems: ExtractedMenuItem[],
userDrinks: UserDrinkSummary[],
preferences: UserPreferenceSummary | null
): Promise<RecommendationResult> {
const prompt = buildRecommendationPrompt(
extractedItems,
userDrinks,
preferences
)
const rawResponse = await this.sendTextRequest(
prompt,
"Please provide your drink recommendations based on the information above."
)
try {
const parsed = this.parseJsonFromResponse(rawResponse)
const recommendations: DrinkRecommendation[] = Array.isArray(parsed)
? parsed
: []
const validatedRecs = recommendations.map((rec) => ({
itemName: String(rec.itemName || ""),
reason: String(rec.reason || ""),
confidence: Math.min(1, Math.max(0, Number(rec.confidence) || 0)),
}))
return { recommendations: validatedRecs, rawResponse }
} catch (error) {
console.error("Failed to parse recommendation response:", error)
return { recommendations: [], rawResponse }
}
}
async extractLabel(
imageBase64: string,
mimeType: string
): Promise<LabelExtractionResult> {
const rawResponse = await this.sendVisionRequest(
LABEL_EXTRACTION_PROMPT,
imageBase64,
mimeType
)
try {
const parsed = this.parseJsonFromResponse(rawResponse)
return {
name: String(parsed.name || "Unknown"),
type: this.validateDrinkType(parsed.type),
...(parsed.subType && { subType: String(parsed.subType) }),
...(parsed.brewery && { brewery: String(parsed.brewery) }),
...(parsed.region && { region: String(parsed.region) }),
...(parsed.abv != null && { abv: Number(parsed.abv) }),
...(parsed.description && { description: String(parsed.description) }),
rawResponse,
}
} catch (error) {
console.error("Failed to parse label extraction response:", error)
return {
name: "Unknown",
type: "OTHER",
rawResponse,
}
}
}
async searchDrinks(query: string): Promise<DrinkSearchResult> {
const rawResponse = await this.sendTextRequest(
DRINK_SEARCH_PROMPT,
`Search for: ${query}`
)
try {
const parsed = this.parseJsonFromResponse(rawResponse)
const drinks: ExtractedMenuItem[] = Array.isArray(parsed) ? parsed : []
const validatedDrinks = drinks.map((item) => ({
name: String(item.name || "Unknown"),
type: this.validateDrinkType(item.type),
...(item.subType && { subType: String(item.subType) }),
...(item.brewery && { brewery: String(item.brewery) }),
...(item.abv != null && { abv: Number(item.abv) }),
...(item.description && { description: String(item.description) }),
}))
return { drinks: validatedDrinks, rawResponse }
} catch (error) {
console.error("Failed to parse drink search response:", error)
return { drinks: [], rawResponse }
}
}
protected parseJsonFromResponse(text: string): any {
// Try direct parse first
try {
return JSON.parse(text)
} catch {
// Continue to other strategies
}
// Try to extract from markdown code blocks: ```json ... ``` or ``` ... ```
const codeBlockMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
if (codeBlockMatch) {
try {
return JSON.parse(codeBlockMatch[1].trim())
} catch {
// Continue to other strategies
}
}
// Try to find JSON array in the text
const arrayMatch = text.match(/\[[\s\S]*\]/)
if (arrayMatch) {
try {
return JSON.parse(arrayMatch[0])
} catch {
// Continue to other strategies
}
}
// Try to find JSON object in the text
const objectMatch = text.match(/\{[\s\S]*\}/)
if (objectMatch) {
try {
return JSON.parse(objectMatch[0])
} catch {
// Continue to other strategies
}
}
throw new Error(`Could not extract valid JSON from response: ${text.slice(0, 200)}...`)
}
private validateDrinkType(
type: unknown
): "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER" {
const validTypes = ["BEER", "WINE", "COCKTAIL", "SPIRIT", "OTHER"] as const
const upper = String(type || "").toUpperCase()
if (validTypes.includes(upper as (typeof validTypes)[number])) {
return upper as (typeof validTypes)[number]
}
return "OTHER"
}
}

View File

@@ -0,0 +1,78 @@
import Anthropic from "@anthropic-ai/sdk"
import { BaseAIProvider } from "./base-provider"
export class ClaudeProvider extends BaseAIProvider {
name = "claude"
private client: Anthropic
constructor(apiKey: string) {
super()
this.client = new Anthropic({ apiKey })
}
async sendVisionRequest(
systemPrompt: string,
imageBase64: string,
mimeType: string
): Promise<string> {
const response = await this.client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
system: systemPrompt,
messages: [
{
role: "user",
content: [
{
type: "image",
source: {
type: "base64",
media_type: mimeType as
| "image/jpeg"
| "image/png"
| "image/gif"
| "image/webp",
data: imageBase64,
},
},
{
type: "text",
text: "Please analyze this image and extract the information as instructed.",
},
],
},
],
})
const textBlock = response.content.find((block) => block.type === "text")
if (!textBlock || textBlock.type !== "text") {
throw new Error("No text response received from Claude")
}
return textBlock.text
}
async sendTextRequest(
systemPrompt: string,
userMessage: string
): Promise<string> {
const response = await this.client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
system: systemPrompt,
messages: [
{
role: "user",
content: userMessage,
},
],
})
const textBlock = response.content.find((block) => block.type === "text")
if (!textBlock || textBlock.type !== "text") {
throw new Error("No text response received from Claude")
}
return textBlock.text
}
}

291
src/lib/ai/menu-analyzer.ts Normal file
View File

@@ -0,0 +1,291 @@
import { prisma } from "@/lib/prisma"
import { decrypt } from "@/lib/encryption"
import { createProvider } from "./provider-factory"
import type {
ExtractedMenuItem,
MenuExtractionResult,
RecommendationResult,
LabelExtractionResult,
UserDrinkSummary,
UserPreferenceSummary,
} from "./types"
interface MatchedItem {
menuItem: ExtractedMenuItem
drinkId: string
drinkName: string
avgRating: number | null
wouldReorder: boolean
}
interface MenuAnalysisResult {
extractedItems: ExtractedMenuItem[]
matchedItems: MatchedItem[]
recommendations: RecommendationResult
rawResponse: string
provider: string
}
async function getProviderForUser(userId: string) {
const apiKeyRecord = await prisma.userApiKey.findFirst({
where: { userId, isActive: true },
orderBy: { updatedAt: "desc" },
})
if (!apiKeyRecord) {
throw new Error(
"No active API key found. Please add an AI provider API key in Settings."
)
}
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
return { provider, providerName: apiKeyRecord.provider }
}
async function getUserDrinkSummaries(
userId: string
): Promise<UserDrinkSummary[]> {
const drinks = await prisma.drink.findMany({
where: { userId },
include: {
ratings: {
select: {
score: true,
wouldReorder: true,
},
},
},
})
return drinks.map((drink) => {
const ratings = drink.ratings
const avgRating =
ratings.length > 0
? ratings.reduce((sum, r) => sum + r.score, 0) / ratings.length
: null
const wouldReorder = ratings.some((r) => r.wouldReorder)
return {
name: drink.name,
type: drink.type,
subType: drink.subType,
brewery: drink.brewery,
avgRating: avgRating !== null ? Math.round(avgRating * 10) / 10 : null,
wouldReorder,
}
})
}
async function getUserPreferences(
userId: string
): Promise<UserPreferenceSummary | null> {
const prefs = await prisma.userPreference.findUnique({
where: { userId },
})
if (!prefs) return null
return {
preferredStyles: prefs.preferredStyles,
avoidedStyles: prefs.avoidedStyles,
minAbv: prefs.minAbv,
maxAbv: prefs.maxAbv,
}
}
function normalizeForComparison(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]/g, "")
.trim()
}
function fuzzyMatch(a: string, b: string): boolean {
const normA = normalizeForComparison(a)
const normB = normalizeForComparison(b)
// Exact match after normalization
if (normA === normB) return true
// One contains the other
if (normA.includes(normB) || normB.includes(normA)) return true
// Levenshtein distance for short strings — allow minor typos
if (normA.length > 3 && normB.length > 3) {
const distance = levenshteinDistance(normA, normB)
const maxLen = Math.max(normA.length, normB.length)
const similarity = 1 - distance / maxLen
if (similarity >= 0.8) return true
}
return false
}
function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = []
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i]
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b[i - 1] === a[j - 1]) {
matrix[i][j] = matrix[i - 1][j - 1]
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
)
}
}
}
return matrix[b.length][a.length]
}
function matchExtractedToUserDrinks(
extractedItems: ExtractedMenuItem[],
userDrinks: Array<{
id: string
name: string
type: string
subType: string | null
brewery: string | null
ratings: Array<{ score: number; wouldReorder: boolean }>
}>
): { matched: MatchedItem[]; unmatched: ExtractedMenuItem[] } {
const matched: MatchedItem[] = []
const unmatched: ExtractedMenuItem[] = []
for (const menuItem of extractedItems) {
let bestMatch: (typeof userDrinks)[number] | null = null
for (const drink of userDrinks) {
// Primary match: name
if (fuzzyMatch(menuItem.name, drink.name)) {
bestMatch = drink
break
}
// Secondary match: name + brewery combo
if (
menuItem.brewery &&
drink.brewery &&
fuzzyMatch(menuItem.brewery, drink.brewery) &&
fuzzyMatch(menuItem.name, drink.name)
) {
bestMatch = drink
break
}
}
if (bestMatch) {
const ratings = bestMatch.ratings
const avgRating =
ratings.length > 0
? Math.round(
(ratings.reduce((sum, r) => sum + r.score, 0) / ratings.length) *
10
) / 10
: null
const wouldReorder = ratings.some((r) => r.wouldReorder)
matched.push({
menuItem,
drinkId: bestMatch.id,
drinkName: bestMatch.name,
avgRating,
wouldReorder,
})
} else {
unmatched.push(menuItem)
}
}
return { matched, unmatched }
}
export async function analyzeMenu(
imageBase64: string,
mimeType: string,
userId: string
): Promise<MenuAnalysisResult> {
// Step 1: Get AI provider for user
const { provider, providerName } = await getProviderForUser(userId)
// Step 2: Extract menu items from image
const extraction: MenuExtractionResult = await provider.extractMenuItems(
imageBase64,
mimeType
)
if (extraction.items.length === 0) {
return {
extractedItems: [],
matchedItems: [],
recommendations: { recommendations: [], rawResponse: extraction.rawResponse },
rawResponse: extraction.rawResponse,
provider: providerName,
}
}
// Step 3: Get user's drinks with ratings from database
const userDrinksWithRatings = await prisma.drink.findMany({
where: { userId },
include: {
ratings: {
select: {
score: true,
wouldReorder: true,
},
},
},
})
// Step 4: Match extracted items against user's collection
const { matched, unmatched } = matchExtractedToUserDrinks(
extraction.items,
userDrinksWithRatings
)
// Step 5: Get recommendations for unmatched items
let recommendations: RecommendationResult = {
recommendations: [],
rawResponse: "",
}
if (unmatched.length > 0) {
const userDrinkSummaries = await getUserDrinkSummaries(userId)
const preferences = await getUserPreferences(userId)
recommendations = await provider.recommendDrinks(
unmatched,
userDrinkSummaries,
preferences
)
}
return {
extractedItems: extraction.items,
matchedItems: matched,
recommendations,
rawResponse: extraction.rawResponse,
provider: providerName,
}
}
export async function analyzeLabel(
imageBase64: string,
mimeType: string,
userId: string
): Promise<LabelExtractionResult> {
const { provider } = await getProviderForUser(userId)
return provider.extractLabel(imageBase64, mimeType)
}

View File

@@ -0,0 +1,81 @@
import OpenAI from "openai"
import { BaseAIProvider } from "./base-provider"
export class OpenAIProvider extends BaseAIProvider {
name = "openai"
private client: OpenAI
constructor(apiKey: string) {
super()
this.client = new OpenAI({ apiKey })
}
async sendVisionRequest(
systemPrompt: string,
imageBase64: string,
mimeType: string
): Promise<string> {
const dataUrl = `data:${mimeType};base64,${imageBase64}`
const response = await this.client.chat.completions.create({
model: "gpt-4o",
max_tokens: 4096,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: dataUrl,
detail: "high",
},
},
{
type: "text",
text: "Please analyze this image and extract the information as instructed.",
},
],
},
],
})
const message = response.choices[0]?.message?.content
if (!message) {
throw new Error("No response received from OpenAI")
}
return message
}
async sendTextRequest(
systemPrompt: string,
userMessage: string
): Promise<string> {
const response = await this.client.chat.completions.create({
model: "gpt-4o",
max_tokens: 4096,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: userMessage,
},
],
})
const message = response.choices[0]?.message?.content
if (!message) {
throw new Error("No response received from OpenAI")
}
return message
}
}

168
src/lib/ai/prompts.ts Normal file
View File

@@ -0,0 +1,168 @@
import type { ExtractedMenuItem, UserDrinkSummary, UserPreferenceSummary } from "./types"
export const MENU_EXTRACTION_PROMPT = `You are an expert at reading drink menus from photos. Analyze the provided menu image and extract every drink item you can identify.
For each item, return the following fields:
- "name" (string, required): The name of the drink as it appears on the menu.
- "type" (string, required): One of "BEER", "WINE", "COCKTAIL", "SPIRIT", or "OTHER".
- "subType" (string, optional): The style or sub-category (e.g., "IPA", "Stout", "Pinot Noir", "Margarita", "Bourbon").
- "brewery" (string, optional): The brewery, winery, or distillery name if listed.
- "abv" (number, optional): The alcohol by volume as a decimal number (e.g., 5.5 for 5.5%). Only include if explicitly shown on the menu.
- "price" (string, optional): The price as shown on the menu (e.g., "$8", "$12/glass"). Include the currency symbol.
- "description" (string, optional): Any tasting notes or description provided on the menu.
Return your response as a valid JSON array of objects. Do not include any text before or after the JSON array. Example format:
[
{
"name": "Hazy Little Thing",
"type": "BEER",
"subType": "Hazy IPA",
"brewery": "Sierra Nevada",
"abv": 6.7,
"price": "$7",
"description": "Unfiltered, unprocessed IPA with tropical hop character"
}
]
If no drink items can be identified in the image, return an empty array: []
Important:
- Extract ALL visible items, even if some fields are unclear.
- If a field is not visible or cannot be determined, omit it rather than guessing.
- Classify the type based on context clues if not explicitly stated.
- For sections labeled "Draft", "On Tap", "Bottles", "Cans" — these are typically BEER.
- For sections labeled "Red", "White", "Rosé", "Sparkling" — these are typically WINE.
- For sections labeled "Cocktails", "Signature Drinks", "Mixed Drinks" — these are typically COCKTAIL.`
export const LABEL_EXTRACTION_PROMPT = `You are an expert at reading drink labels from photos. Analyze the provided label image and extract all information about the drink.
Return your response as a single valid JSON object with the following fields:
- "name" (string, required): The name of the drink.
- "type" (string, required): One of "BEER", "WINE", "COCKTAIL", "SPIRIT", or "OTHER".
- "subType" (string, optional): The style or sub-category (e.g., "IPA", "Stout", "Cabernet Sauvignon", "Bourbon").
- "brewery" (string, optional): The brewery, winery, or distillery name.
- "region" (string, optional): The geographic region or origin (e.g., "Napa Valley", "Portland, OR", "Scotland").
- "abv" (number, optional): The alcohol by volume as a decimal number (e.g., 5.5 for 5.5%).
- "description" (string, optional): Any tasting notes, taglines, or descriptive text from the label.
Do not include any text before or after the JSON object. Example format:
{
"name": "Two Hearted Ale",
"type": "BEER",
"subType": "American IPA",
"brewery": "Bell's Brewery",
"region": "Comstock, MI",
"abv": 7.0,
"description": "Brewed with 100% Centennial hops for a bold, balanced American IPA"
}
Important:
- Read the label carefully and extract only what is actually present.
- If a field is not visible or cannot be determined, omit it rather than guessing.
- For ABV, look for the "% alc/vol" or "ABV" label. Return only the number.`
export const DRINK_SEARCH_PROMPT = `You are a knowledgeable drink expert. The user is searching for a drink by name or description. Return detailed information about matching drinks.
Return your response as a valid JSON array of drink objects. Each object should have:
- "name" (string, required): The full, correct name of the drink.
- "type" (string, required): One of "BEER", "WINE", "COCKTAIL", "SPIRIT", or "OTHER".
- "subType" (string, optional): The style or sub-category (e.g., "IPA", "Stout", "Cabernet Sauvignon").
- "brewery" (string, optional): The brewery, winery, or distillery that makes it.
- "region" (string, optional): Where it's from.
- "abv" (number, optional): Typical ABV as a number.
- "description" (string, optional): A brief tasting note or description (1-2 sentences).
Important:
- Return up to 8 results, sorted by relevance to the query.
- Include the most likely exact match first, followed by similar or related drinks.
- If the query is vague (e.g., "a good IPA"), return popular well-known options.
- Only include information you are confident about. Omit fields rather than guessing.
- Do not include any text before or after the JSON array.`
export function buildRecommendationPrompt(
extractedItems: ExtractedMenuItem[],
userDrinks: UserDrinkSummary[],
preferences: UserPreferenceSummary | null
): string {
const itemsList = extractedItems
.map((item, i) => {
const parts = [`${i + 1}. ${item.name} (${item.type})`]
if (item.subType) parts.push(`Style: ${item.subType}`)
if (item.brewery) parts.push(`From: ${item.brewery}`)
if (item.abv) parts.push(`ABV: ${item.abv}%`)
if (item.description) parts.push(`Description: ${item.description}`)
return parts.join(" | ")
})
.join("\n")
const drinkHistory = userDrinks.length > 0
? userDrinks
.map((d) => {
const parts = [`- ${d.name} (${d.type})`]
if (d.subType) parts.push(`Style: ${d.subType}`)
if (d.brewery) parts.push(`From: ${d.brewery}`)
if (d.avgRating !== null) parts.push(`Avg Rating: ${d.avgRating}/5`)
parts.push(`Would Reorder: ${d.wouldReorder ? "Yes" : "No"}`)
return parts.join(" | ")
})
.join("\n")
: "No drink history available."
let preferencesText = "No specific preferences set."
if (preferences) {
const parts: string[] = []
if (preferences.preferredStyles.length > 0) {
parts.push(`Preferred styles: ${preferences.preferredStyles.join(", ")}`)
}
if (preferences.avoidedStyles.length > 0) {
parts.push(`Avoided styles: ${preferences.avoidedStyles.join(", ")}`)
}
if (preferences.minAbv != null) {
parts.push(`Minimum ABV: ${preferences.minAbv}%`)
}
if (preferences.maxAbv != null) {
parts.push(`Maximum ABV: ${preferences.maxAbv}%`)
}
if (parts.length > 0) {
preferencesText = parts.join("\n")
}
}
return `You are a knowledgeable drink recommendation assistant. Based on the user's drink history, preferences, and the available menu items, recommend drinks they would likely enjoy.
## User's Drink History
${drinkHistory}
## User's Preferences
${preferencesText}
## Available Menu Items
${itemsList}
## Instructions
Analyze the user's taste profile from their drink history and preferences. Then recommend items from the available menu that they would most likely enjoy. Consider:
- Drinks similar to ones they rated highly or would reorder
- Styles they prefer
- Avoid styles they dislike
- Respect their ABV range preferences if set
- If they have no history, recommend popular crowd-pleasers
Return your response as a valid JSON array of recommendation objects. Each object should have:
- "itemName" (string): The exact name of the menu item you are recommending.
- "reason" (string): A brief, personalized explanation of why you think they would enjoy this drink (1-2 sentences).
- "confidence" (number): A confidence score between 0 and 1 indicating how well this matches their taste profile.
Sort recommendations by confidence (highest first). Return up to 5 recommendations.
Do not include any text before or after the JSON array. Example format:
[
{
"itemName": "Hazy Little Thing",
"reason": "You've rated several IPAs highly, and this hazy IPA has similar tropical hop notes to beers you've enjoyed.",
"confidence": 0.92
}
]`
}

View File

@@ -0,0 +1,14 @@
import type { AIProvider } from "./types"
import { ClaudeProvider } from "./claude-provider"
import { OpenAIProvider } from "./openai-provider"
export function createProvider(providerName: string, apiKey: string): AIProvider {
switch (providerName) {
case "claude":
return new ClaudeProvider(apiKey)
case "openai":
return new OpenAIProvider(apiKey)
default:
throw new Error(`Unknown AI provider: "${providerName}". Supported providers: "claude", "openai".`)
}
}

69
src/lib/ai/types.ts Normal file
View File

@@ -0,0 +1,69 @@
export interface ExtractedMenuItem {
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType?: string
brewery?: string
abv?: number
price?: string
description?: string
}
export interface MenuExtractionResult {
items: ExtractedMenuItem[]
rawResponse: string
}
export interface DrinkRecommendation {
itemName: string
reason: string
confidence: number // 0-1
}
export interface RecommendationResult {
recommendations: DrinkRecommendation[]
rawResponse: string
}
export interface LabelExtractionResult {
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType?: string
brewery?: string
region?: string
abv?: number
description?: string
rawResponse: string
}
export interface DrinkSearchResult {
drinks: ExtractedMenuItem[]
rawResponse: string
}
export interface AIProvider {
name: string
extractMenuItems(imageBase64: string, mimeType: string): Promise<MenuExtractionResult>
recommendDrinks(
extractedItems: ExtractedMenuItem[],
userDrinks: UserDrinkSummary[],
preferences: UserPreferenceSummary | null
): Promise<RecommendationResult>
extractLabel(imageBase64: string, mimeType: string): Promise<LabelExtractionResult>
searchDrinks(query: string): Promise<DrinkSearchResult>
}
export interface UserDrinkSummary {
name: string
type: string
subType?: string | null
brewery?: string | null
avgRating: number | null
wouldReorder: boolean
}
export interface UserPreferenceSummary {
preferredStyles: string[]
avoidedStyles: string[]
minAbv?: number | null
maxAbv?: number | null
}

83
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,83 @@
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import GitHub from "next-auth/providers/github"
import Credentials from "next-auth/providers/credentials"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
const providers = [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
Credentials({
name: "Email",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const email = credentials?.email as string
const password = credentials?.password as string
if (!email || !password) return null
const user = await prisma.user.findUnique({ where: { email } })
if (!user || !user.password) return null
const bcrypt = await import("bcryptjs")
const valid = await bcrypt.compare(password, user.password)
if (!valid) return null
return { id: user.id, email: user.email, name: user.name, image: user.image }
},
}),
]
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers,
session: {
strategy: "jwt", // needed for credentials provider
},
pages: {
signIn: "/login",
},
callbacks: {
jwt({ token, user }) {
if (user) {
token.id = user.id
}
return token
},
session({ session, token }) {
if (session.user && token.id) {
session.user.id = token.id as string
}
return session
},
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user
const isOnApp = nextUrl.pathname.startsWith("/dashboard") ||
nextUrl.pathname.startsWith("/scan") ||
nextUrl.pathname.startsWith("/drinks") ||
nextUrl.pathname.startsWith("/rate") ||
nextUrl.pathname.startsWith("/settings") ||
nextUrl.pathname.startsWith("/wishlist")
if (isOnApp) {
if (isLoggedIn) return true
return false
}
if (isLoggedIn && (nextUrl.pathname === "/login" || nextUrl.pathname === "/register")) {
return Response.redirect(new URL("/dashboard", nextUrl))
}
return true
},
},
})

713
src/lib/backup.ts Normal file
View File

@@ -0,0 +1,713 @@
import { objectsToCsv } from "@/lib/csv"
import { prisma } from "@/lib/prisma"
import type {
Drink,
Rating,
WishlistItem,
UserPreference,
SharedList,
} from "@prisma/client"
import crypto from "crypto"
// ─── CSV Column Schema ──────────────────────────────────────────
const CSV_HEADERS = [
"_type",
"_originalId",
"_parentId",
"_drinkName",
"name",
"type",
"subType",
"brewery",
"region",
"abv",
"description",
"imageUrl",
"score",
"notes",
"wouldReorder",
"location",
"source",
"slug",
"title",
"listType",
"isPublic",
"drinkIds",
"preferredStyles",
"avoidedStyles",
"minAbv",
"maxAbv",
"defaultProvider",
"createdAt",
"updatedAt",
]
const VALID_DRINK_TYPES = ["BEER", "WINE", "COCKTAIL", "SPIRIT", "OTHER"]
// ─── Parsed Types ───────────────────────────────────────────────
export interface ParsedDrink {
_originalId: string
name: string
type: string
subType?: string
brewery?: string
region?: string
abv?: number
description?: string
imageUrl?: string
createdAt?: Date
updatedAt?: Date
}
export interface ParsedRating {
_originalId: string
_parentId: string
_drinkName: string
score: number
notes?: string
wouldReorder: boolean
location?: string
createdAt?: Date
updatedAt?: Date
}
export interface ParsedWishlistItem {
_originalId: string
name: string
type: string
subType?: string
brewery?: string
abv?: number
description?: string
notes?: string
source?: string
createdAt?: Date
updatedAt?: Date
}
export interface ParsedPreferences {
preferredStyles: string[]
avoidedStyles: string[]
minAbv?: number
maxAbv?: number
defaultProvider?: string
}
export interface ParsedSharedList {
_originalId: string
title: string
description?: string
listType: string
isPublic: boolean
drinkIds: string[]
createdAt?: Date
updatedAt?: Date
}
export interface ParsedBackupData {
drinks: ParsedDrink[]
ratings: ParsedRating[]
wishlistItems: ParsedWishlistItem[]
preferences: ParsedPreferences | null
sharedLists: ParsedSharedList[]
}
export interface RestoreSummary {
drinks: { created: number; updated: number; skipped: number }
ratings: { created: number; updated: number; skipped: number }
wishlist: { created: number; updated: number; skipped: number }
preferences: { restored: boolean }
sharedLists: { created: number; updated: number; skipped: number }
}
// ─── Export ─────────────────────────────────────────────────────
export function generateBackupCsv(
drinks: Drink[],
ratings: (Rating & { drink: { name: string } })[],
wishlistItems: WishlistItem[],
preferences: UserPreference | null,
sharedLists: SharedList[]
): string {
const rows: Record<string, string>[] = []
// Drinks
for (const d of drinks) {
rows.push({
_type: "drink",
_originalId: d.id,
name: d.name,
type: d.type,
subType: d.subType ?? "",
brewery: d.brewery ?? "",
region: d.region ?? "",
abv: d.abv != null ? String(d.abv) : "",
description: d.description ?? "",
imageUrl: d.imageUrl ?? "",
createdAt: d.createdAt.toISOString(),
updatedAt: d.updatedAt.toISOString(),
})
}
// Ratings
for (const r of ratings) {
rows.push({
_type: "rating",
_originalId: r.id,
_parentId: r.drinkId,
_drinkName: r.drink.name,
score: String(r.score),
notes: r.notes ?? "",
wouldReorder: String(r.wouldReorder),
location: r.location ?? "",
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})
}
// Wishlist items
for (const w of wishlistItems) {
rows.push({
_type: "wishlist",
_originalId: w.id,
name: w.name,
type: w.type,
subType: w.subType ?? "",
brewery: w.brewery ?? "",
abv: w.abv != null ? String(w.abv) : "",
description: w.description ?? "",
notes: w.notes ?? "",
source: w.source ?? "",
createdAt: w.createdAt.toISOString(),
updatedAt: w.updatedAt.toISOString(),
})
}
// Preferences
if (preferences) {
rows.push({
_type: "preference",
preferredStyles: preferences.preferredStyles.join("|"),
avoidedStyles: preferences.avoidedStyles.join("|"),
minAbv: preferences.minAbv != null ? String(preferences.minAbv) : "",
maxAbv: preferences.maxAbv != null ? String(preferences.maxAbv) : "",
defaultProvider: preferences.defaultProvider ?? "",
})
}
// Shared lists
for (const s of sharedLists) {
rows.push({
_type: "shared_list",
_originalId: s.id,
title: s.title,
description: s.description ?? "",
listType: s.listType,
isPublic: String(s.isPublic),
drinkIds: s.drinkIds.join("|"),
createdAt: s.createdAt.toISOString(),
updatedAt: s.updatedAt.toISOString(),
})
}
return objectsToCsv(CSV_HEADERS, rows)
}
// ─── Import: Parse ──────────────────────────────────────────────
function parseBoolean(val: string): boolean {
const lower = val.toLowerCase().trim()
return lower === "true" || lower === "1" || lower === "yes"
}
function parseOptionalFloat(val: string): number | undefined {
if (!val || val.trim() === "") return undefined
const n = parseFloat(val)
return isNaN(n) ? undefined : n
}
function parseOptionalDate(val: string): Date | undefined {
if (!val || val.trim() === "") return undefined
const d = new Date(val)
return isNaN(d.getTime()) ? undefined : d
}
export function parseBackupRows(
rows: Record<string, string>[]
): ParsedBackupData {
const data: ParsedBackupData = {
drinks: [],
ratings: [],
wishlistItems: [],
preferences: null,
sharedLists: [],
}
for (const row of rows) {
const rowType = row._type?.trim().toLowerCase()
switch (rowType) {
case "drink":
data.drinks.push({
_originalId: row._originalId ?? "",
name: row.name ?? "",
type: row.type ?? "OTHER",
subType: row.subType || undefined,
brewery: row.brewery || undefined,
region: row.region || undefined,
abv: parseOptionalFloat(row.abv ?? ""),
description: row.description || undefined,
imageUrl: row.imageUrl || undefined,
createdAt: parseOptionalDate(row.createdAt ?? ""),
updatedAt: parseOptionalDate(row.updatedAt ?? ""),
})
break
case "rating":
data.ratings.push({
_originalId: row._originalId ?? "",
_parentId: row._parentId ?? "",
_drinkName: row._drinkName ?? "",
score: parseInt(row.score ?? "0", 10),
notes: row.notes || undefined,
wouldReorder: parseBoolean(row.wouldReorder ?? "false"),
location: row.location || undefined,
createdAt: parseOptionalDate(row.createdAt ?? ""),
updatedAt: parseOptionalDate(row.updatedAt ?? ""),
})
break
case "wishlist":
data.wishlistItems.push({
_originalId: row._originalId ?? "",
name: row.name ?? "",
type: row.type ?? "OTHER",
subType: row.subType || undefined,
brewery: row.brewery || undefined,
abv: parseOptionalFloat(row.abv ?? ""),
description: row.description || undefined,
notes: row.notes || undefined,
source: row.source || undefined,
createdAt: parseOptionalDate(row.createdAt ?? ""),
updatedAt: parseOptionalDate(row.updatedAt ?? ""),
})
break
case "preference":
data.preferences = {
preferredStyles: row.preferredStyles
? row.preferredStyles.split("|").filter(Boolean)
: [],
avoidedStyles: row.avoidedStyles
? row.avoidedStyles.split("|").filter(Boolean)
: [],
minAbv: parseOptionalFloat(row.minAbv ?? ""),
maxAbv: parseOptionalFloat(row.maxAbv ?? ""),
defaultProvider: row.defaultProvider || undefined,
}
break
case "shared_list":
data.sharedLists.push({
_originalId: row._originalId ?? "",
title: row.title ?? "",
description: row.description || undefined,
listType: row.listType ?? "collection",
isPublic: parseBoolean(row.isPublic ?? "true"),
drinkIds: row.drinkIds
? row.drinkIds.split("|").filter(Boolean)
: [],
createdAt: parseOptionalDate(row.createdAt ?? ""),
updatedAt: parseOptionalDate(row.updatedAt ?? ""),
})
break
// Skip unknown row types
}
}
return data
}
// ─── Import: Validate ───────────────────────────────────────────
export function validateBackupData(
data: ParsedBackupData
): { valid: boolean; errors: string[] } {
const errors: string[] = []
for (let i = 0; i < data.drinks.length; i++) {
const d = data.drinks[i]
if (!d.name) errors.push(`Drink row ${i + 1}: name is required`)
if (!VALID_DRINK_TYPES.includes(d.type))
errors.push(
`Drink row ${i + 1}: invalid type "${d.type}". Expected one of: ${VALID_DRINK_TYPES.join(", ")}`
)
if (d.abv != null && (d.abv < 0 || d.abv > 100))
errors.push(`Drink row ${i + 1}: ABV must be 0-100`)
}
for (let i = 0; i < data.ratings.length; i++) {
const r = data.ratings[i]
if (!r.score || r.score < 1 || r.score > 5)
errors.push(`Rating row ${i + 1}: score must be 1-5`)
if (!r._parentId && !r._drinkName)
errors.push(
`Rating row ${i + 1}: must have _parentId or _drinkName to link to a drink`
)
}
for (let i = 0; i < data.wishlistItems.length; i++) {
const w = data.wishlistItems[i]
if (!w.name) errors.push(`Wishlist row ${i + 1}: name is required`)
if (!VALID_DRINK_TYPES.includes(w.type))
errors.push(
`Wishlist row ${i + 1}: invalid type "${w.type}". Expected one of: ${VALID_DRINK_TYPES.join(", ")}`
)
}
for (let i = 0; i < data.sharedLists.length; i++) {
const s = data.sharedLists[i]
if (!s.title) errors.push(`Shared list row ${i + 1}: title is required`)
}
return { valid: errors.length === 0, errors }
}
// ─── Import: Execute Restore ────────────────────────────────────
type RestoreMode = "merge-skip" | "merge-update" | "replace"
type DrinkType = "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
export async function executeRestore(
userId: string,
data: ParsedBackupData,
mode: RestoreMode
): Promise<RestoreSummary> {
return await prisma.$transaction(
async (tx) => {
const summary: RestoreSummary = {
drinks: { created: 0, updated: 0, skipped: 0 },
ratings: { created: 0, updated: 0, skipped: 0 },
wishlist: { created: 0, updated: 0, skipped: 0 },
preferences: { restored: false },
sharedLists: { created: 0, updated: 0, skipped: 0 },
}
// STEP 0: If "replace", delete everything first
if (mode === "replace") {
await tx.rating.deleteMany({ where: { userId } })
await tx.sharedList.deleteMany({ where: { userId } })
await tx.drink.deleteMany({ where: { userId } })
await tx.wishlistItem.deleteMany({ where: { userId } })
await tx.userPreference.deleteMany({ where: { userId } })
}
// STEP 1: Preferences
if (data.preferences) {
if (mode === "replace") {
await tx.userPreference.create({
data: {
userId,
preferredStyles: data.preferences.preferredStyles,
avoidedStyles: data.preferences.avoidedStyles,
minAbv: data.preferences.minAbv ?? null,
maxAbv: data.preferences.maxAbv ?? null,
defaultProvider: data.preferences.defaultProvider ?? null,
},
})
summary.preferences.restored = true
} else {
const existing = await tx.userPreference.findUnique({
where: { userId },
})
if (!existing) {
await tx.userPreference.create({
data: {
userId,
preferredStyles: data.preferences.preferredStyles,
avoidedStyles: data.preferences.avoidedStyles,
minAbv: data.preferences.minAbv ?? null,
maxAbv: data.preferences.maxAbv ?? null,
defaultProvider: data.preferences.defaultProvider ?? null,
},
})
summary.preferences.restored = true
} else if (mode === "merge-update") {
await tx.userPreference.update({
where: { userId },
data: {
preferredStyles: data.preferences.preferredStyles,
avoidedStyles: data.preferences.avoidedStyles,
minAbv: data.preferences.minAbv ?? null,
maxAbv: data.preferences.maxAbv ?? null,
defaultProvider: data.preferences.defaultProvider ?? null,
},
})
summary.preferences.restored = true
}
}
}
// STEP 2: Drinks (build drinkIdMap)
const drinkIdMap = new Map<string, string>()
for (const drink of data.drinks) {
if (mode === "replace") {
const created = await tx.drink.create({
data: {
userId,
name: drink.name,
type: drink.type as DrinkType,
subType: drink.subType ?? null,
brewery: drink.brewery ?? null,
region: drink.region ?? null,
abv: drink.abv ?? null,
description: drink.description ?? null,
imageUrl: drink.imageUrl ?? null,
},
})
drinkIdMap.set(drink._originalId, created.id)
summary.drinks.created++
} else {
const existing = await tx.drink.findFirst({
where: {
userId,
name: drink.name,
type: drink.type as DrinkType,
brewery: drink.brewery ?? null,
},
})
if (existing) {
drinkIdMap.set(drink._originalId, existing.id)
if (mode === "merge-update") {
await tx.drink.update({
where: { id: existing.id },
data: {
subType: drink.subType ?? null,
region: drink.region ?? null,
abv: drink.abv ?? null,
description: drink.description ?? null,
imageUrl: drink.imageUrl ?? null,
},
})
summary.drinks.updated++
} else {
summary.drinks.skipped++
}
} else {
const created = await tx.drink.create({
data: {
userId,
name: drink.name,
type: drink.type as DrinkType,
subType: drink.subType ?? null,
brewery: drink.brewery ?? null,
region: drink.region ?? null,
abv: drink.abv ?? null,
description: drink.description ?? null,
imageUrl: drink.imageUrl ?? null,
},
})
drinkIdMap.set(drink._originalId, created.id)
summary.drinks.created++
}
}
}
// STEP 3: Ratings (use drinkIdMap)
for (const rating of data.ratings) {
let drinkId = drinkIdMap.get(rating._parentId)
// Fallback: match by drink name
if (!drinkId && rating._drinkName) {
const drinkByName = await tx.drink.findFirst({
where: { userId, name: rating._drinkName },
})
if (drinkByName) drinkId = drinkByName.id
}
if (!drinkId) {
summary.ratings.skipped++
continue
}
if (mode === "replace") {
await tx.rating.create({
data: {
userId,
drinkId,
score: rating.score,
notes: rating.notes ?? null,
wouldReorder: rating.wouldReorder,
location: rating.location ?? null,
},
})
summary.ratings.created++
} else {
const existing = await tx.rating.findFirst({
where: {
userId,
drinkId,
score: rating.score,
...(rating.createdAt ? { createdAt: rating.createdAt } : {}),
},
})
if (existing) {
if (mode === "merge-update") {
await tx.rating.update({
where: { id: existing.id },
data: {
notes: rating.notes ?? null,
wouldReorder: rating.wouldReorder,
location: rating.location ?? null,
},
})
summary.ratings.updated++
} else {
summary.ratings.skipped++
}
} else {
await tx.rating.create({
data: {
userId,
drinkId,
score: rating.score,
notes: rating.notes ?? null,
wouldReorder: rating.wouldReorder,
location: rating.location ?? null,
},
})
summary.ratings.created++
}
}
}
// STEP 4: Wishlist items
for (const item of data.wishlistItems) {
if (mode === "replace") {
await tx.wishlistItem.create({
data: {
userId,
name: item.name,
type: item.type as DrinkType,
subType: item.subType ?? null,
brewery: item.brewery ?? null,
abv: item.abv ?? null,
description: item.description ?? null,
notes: item.notes ?? null,
source: item.source ?? null,
},
})
summary.wishlist.created++
} else {
const existing = await tx.wishlistItem.findFirst({
where: {
userId,
name: item.name,
type: item.type as DrinkType,
},
})
if (existing) {
if (mode === "merge-update") {
await tx.wishlistItem.update({
where: { id: existing.id },
data: {
subType: item.subType ?? null,
brewery: item.brewery ?? null,
abv: item.abv ?? null,
description: item.description ?? null,
notes: item.notes ?? null,
source: item.source ?? null,
},
})
summary.wishlist.updated++
} else {
summary.wishlist.skipped++
}
} else {
await tx.wishlistItem.create({
data: {
userId,
name: item.name,
type: item.type as DrinkType,
subType: item.subType ?? null,
brewery: item.brewery ?? null,
abv: item.abv ?? null,
description: item.description ?? null,
notes: item.notes ?? null,
source: item.source ?? null,
},
})
summary.wishlist.created++
}
}
}
// STEP 5: Shared lists (map drinkIds)
for (const list of data.sharedLists) {
const mappedDrinkIds = list.drinkIds
.map((oldId) => drinkIdMap.get(oldId))
.filter(Boolean) as string[]
const slug = crypto.randomBytes(6).toString("hex")
if (mode === "replace") {
await tx.sharedList.create({
data: {
userId,
slug,
title: list.title,
description: list.description ?? null,
listType: list.listType,
isPublic: list.isPublic,
drinkIds: mappedDrinkIds,
},
})
summary.sharedLists.created++
} else {
const existing = await tx.sharedList.findFirst({
where: {
userId,
title: list.title,
listType: list.listType,
},
})
if (existing) {
if (mode === "merge-update") {
await tx.sharedList.update({
where: { id: existing.id },
data: {
description: list.description ?? null,
isPublic: list.isPublic,
drinkIds: mappedDrinkIds,
},
})
summary.sharedLists.updated++
} else {
summary.sharedLists.skipped++
}
} else {
await tx.sharedList.create({
data: {
userId,
slug,
title: list.title,
description: list.description ?? null,
listType: list.listType,
isPublic: list.isPublic,
drinkIds: mappedDrinkIds,
},
})
summary.sharedLists.created++
}
}
}
return summary
},
{ timeout: 60000 }
)
}

121
src/lib/csv.ts Normal file
View File

@@ -0,0 +1,121 @@
/**
* CSV utilities — RFC 4180 compliant, no external dependencies.
*/
/** Escape a field value for CSV (wrap in quotes if needed) */
function escapeField(value: string): string {
if (
value.includes(",") ||
value.includes('"') ||
value.includes("\n") ||
value.includes("\r")
) {
return `"${value.replace(/"/g, '""')}"`
}
return value
}
/** Convert an array of objects to a CSV string */
export function objectsToCsv(
headers: string[],
rows: Record<string, string>[]
): string {
const lines: string[] = []
// Header row
lines.push(headers.map(escapeField).join(","))
// Data rows
for (const row of rows) {
const fields = headers.map((h) => escapeField(row[h] ?? ""))
lines.push(fields.join(","))
}
return lines.join("\n")
}
/**
* Parse a CSV string into an array of objects keyed by header names.
* Uses a state-machine parser to correctly handle quoted fields.
*/
export function csvToObjects(csvText: string): Record<string, string>[] {
const rows = parseCsvRows(csvText)
if (rows.length < 1) return []
const headers = rows[0]
const result: Record<string, string>[] = []
for (let i = 1; i < rows.length; i++) {
const row = rows[i]
// Skip empty rows
if (row.length === 1 && row[0] === "") continue
const obj: Record<string, string> = {}
for (let j = 0; j < headers.length; j++) {
obj[headers[j]] = row[j] ?? ""
}
result.push(obj)
}
return result
}
/** State-machine CSV parser that handles quoted fields correctly */
function parseCsvRows(text: string): string[][] {
const rows: string[][] = []
let currentRow: string[] = []
let currentField = ""
let inQuotes = false
for (let i = 0; i < text.length; i++) {
const char = text[i]
const nextChar = text[i + 1]
if (inQuotes) {
if (char === '"') {
if (nextChar === '"') {
// Escaped quote
currentField += '"'
i++ // skip next quote
} else {
// End of quoted field
inQuotes = false
}
} else {
currentField += char
}
} else {
if (char === '"' && currentField === "") {
// Start of quoted field
inQuotes = true
} else if (char === ",") {
currentRow.push(currentField)
currentField = ""
} else if (char === "\n") {
currentRow.push(currentField)
currentField = ""
rows.push(currentRow)
currentRow = []
} else if (char === "\r") {
// Skip carriage return (handle \r\n)
if (nextChar === "\n") {
i++ // skip \n
}
currentRow.push(currentField)
currentField = ""
rows.push(currentRow)
currentRow = []
} else {
currentField += char
}
}
}
// Handle last field/row
if (currentField !== "" || currentRow.length > 0) {
currentRow.push(currentField)
rows.push(currentRow)
}
return rows
}

33
src/lib/encryption.ts Normal file
View File

@@ -0,0 +1,33 @@
import { createCipheriv, createDecipheriv, randomBytes } from "crypto"
const ALGORITHM = "aes-256-cbc"
function getEncryptionKey(): Buffer {
const key = process.env.ENCRYPTION_KEY
if (!key) throw new Error("ENCRYPTION_KEY environment variable is required")
return Buffer.from(key, "hex")
}
export function encrypt(text: string): { encrypted: string; iv: string } {
const iv = randomBytes(16)
const cipher = createCipheriv(ALGORITHM, getEncryptionKey(), iv)
let encrypted = cipher.update(text, "utf8", "hex")
encrypted += cipher.final("hex")
return { encrypted, iv: iv.toString("hex") }
}
export function decrypt(encrypted: string, iv: string): string {
const decipher = createDecipheriv(
ALGORITHM,
getEncryptionKey(),
Buffer.from(iv, "hex")
)
let decrypted = decipher.update(encrypted, "hex", "utf8")
decrypted += decipher.final("utf8")
return decrypted
}
export function maskApiKey(key: string): string {
if (key.length <= 8) return "****"
return key.slice(0, 4) + "..." + key.slice(-4)
}

9
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,9 @@
import { PrismaClient } from "@prisma/client"
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma

34
src/lib/rate-limit.ts Normal file
View File

@@ -0,0 +1,34 @@
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
export function rateLimit(
key: string,
limit: number = 10,
windowMs: number = 60 * 1000
): { success: boolean; remaining: number } {
const now = Date.now()
const entry = rateLimitMap.get(key)
if (!entry || now > entry.resetTime) {
rateLimitMap.set(key, { count: 1, resetTime: now + windowMs })
return { success: true, remaining: limit - 1 }
}
if (entry.count >= limit) {
return { success: false, remaining: 0 }
}
entry.count++
return { success: true, remaining: limit - entry.count }
}
// Clean up expired entries periodically
if (typeof setInterval !== "undefined") {
setInterval(() => {
const now = Date.now()
rateLimitMap.forEach((entry, key) => {
if (now > entry.resetTime) {
rateLimitMap.delete(key)
}
})
}, 60 * 1000)
}

62
src/lib/s3.ts Normal file
View File

@@ -0,0 +1,62 @@
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
} from "@aws-sdk/client-s3"
const s3Client = new S3Client({
endpoint: `http${process.env.MINIO_USE_SSL === "true" ? "s" : ""}://${process.env.MINIO_ENDPOINT}:${process.env.MINIO_PORT}`,
region: "us-east-1",
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY!,
secretAccessKey: process.env.MINIO_SECRET_KEY!,
},
forcePathStyle: true,
})
const BUCKET = process.env.MINIO_BUCKET || "drink-images"
export async function uploadImage(
key: string,
body: Buffer,
contentType: string
): Promise<string> {
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: body,
ContentType: contentType,
})
)
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}`
}
export async function getImage(key: string) {
const response = await s3Client.send(
new GetObjectCommand({
Bucket: BUCKET,
Key: key,
})
)
return response
}
export async function deleteImage(key: string) {
await s3Client.send(
new DeleteObjectCommand({
Bucket: BUCKET,
Key: key,
})
)
}
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}`
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

73
src/lib/validators.ts Normal file
View File

@@ -0,0 +1,73 @@
import { z } from "zod"
export const drinkCreateSchema = z.object({
name: z.string().min(1, "Name is required").max(200),
type: z.enum(["BEER", "WINE", "COCKTAIL", "SPIRIT", "OTHER"]),
subType: z.string().max(100).optional(),
brewery: z.string().max(200).optional(),
region: z.string().max(200).optional(),
abv: z.number().min(0).max(100).optional(),
description: z.string().max(2000).optional(),
imageUrl: z.string().url().optional(),
})
export const drinkUpdateSchema = drinkCreateSchema.partial()
export const ratingCreateSchema = z.object({
drinkId: z.string().min(1),
score: z.number().int().min(1).max(5),
notes: z.string().max(2000).optional(),
wouldReorder: z.boolean().optional(),
location: z.string().max(200).optional(),
})
export const ratingUpdateSchema = ratingCreateSchema.omit({ drinkId: true }).partial()
export const apiKeySchema = z.object({
provider: z.enum(["claude", "openai"]),
apiKey: z.string().min(1, "API key is required"),
label: z.string().max(100).optional(),
})
export const userPreferenceSchema = z.object({
preferredStyles: z.array(z.string().max(50)).max(20).optional(),
avoidedStyles: z.array(z.string().max(50)).max(20).optional(),
minAbv: z.number().min(0).max(100).optional().nullable(),
maxAbv: z.number().min(0).max(100).optional().nullable(),
defaultProvider: z.enum(["claude", "openai"]).optional().nullable(),
})
export const wishlistCreateSchema = z.object({
name: z.string().min(1, "Name is required").max(200),
type: z.enum(["BEER", "WINE", "COCKTAIL", "SPIRIT", "OTHER"]),
subType: z.string().max(100).optional(),
brewery: z.string().max(200).optional(),
abv: z.number().min(0).max(100).optional(),
description: z.string().max(2000).optional(),
notes: z.string().max(2000).optional(),
source: z.string().max(50).optional(),
})
export const sharedListCreateSchema = z.object({
title: z.string().min(1, "Title is required").max(200),
description: z.string().max(2000).optional(),
listType: z.enum(["collection", "wishlist", "custom"]).default("collection"),
isPublic: z.boolean().default(true),
drinkIds: z.array(z.string()).default([]),
})
export const sharedListUpdateSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional().nullable(),
isPublic: z.boolean().optional(),
})
export type DrinkCreate = z.infer<typeof drinkCreateSchema>
export type DrinkUpdate = z.infer<typeof drinkUpdateSchema>
export type RatingCreate = z.infer<typeof ratingCreateSchema>
export type RatingUpdate = z.infer<typeof ratingUpdateSchema>
export type ApiKeyInput = z.infer<typeof apiKeySchema>
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>

12
src/middleware.ts Normal file
View File

@@ -0,0 +1,12 @@
export { auth as middleware } from "@/lib/auth"
export const config = {
matcher: [
"/dashboard/:path*",
"/scan/:path*",
"/drinks/:path*",
"/rate/:path*",
"/settings/:path*",
"/wishlist/:path*",
],
}

9
src/types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import { DefaultSession } from "next-auth"
declare module "next-auth" {
interface Session {
user: {
id: string
} & DefaultSession["user"]
}
}