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>
)
}