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

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.next
.git
.gitignore
.env
.env.local
.env.production
*.md
.claude

26
.env.example Normal file
View File

@@ -0,0 +1,26 @@
# Database
DATABASE_URL="postgresql://drinktracker:YOUR_PASSWORD@localhost:5432/drinktracker"
POSTGRES_USER="drinktracker"
POSTGRES_PASSWORD="YOUR_PASSWORD"
POSTGRES_DB="drinktracker"
# NextAuth
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="generate-with: openssl rand -base64 32"
# OAuth Providers
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
# MinIO / S3-compatible storage
MINIO_ENDPOINT="localhost"
MINIO_PORT="9000"
MINIO_ACCESS_KEY="generate-a-strong-access-key"
MINIO_SECRET_KEY="generate-a-strong-secret-key"
MINIO_BUCKET="drink-images"
MINIO_USE_SSL="false"
# Encryption (for API key storage)
ENCRYPTION_KEY="generate-with: openssl rand -hex 32"

8
.eslintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-empty-object-type": "off",
"@next/next/no-img-element": "warn"
}
}

42
.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.production
.env*.local
# claude
.claude/
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

45
Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Stage 2: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
# Stage 3: Production runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

17
components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

65
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,65 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env.production}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.production}
POSTGRES_DB: ${POSTGRES_DB:-drinktracker}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U drinktracker -d drinktracker"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in .env.production}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in .env.production}
volumes:
- miniodata:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 5
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $$MINIO_ACCESS_KEY $$MINIO_SECRET_KEY;
mc mb local/drink-images --ignore-existing;
mc anonymous set download local/drink-images;
exit 0;
"
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
env_file:
- .env.production
volumes:
pgdata:
miniodata:

77
docker-compose.yml Normal file
View File

@@ -0,0 +1,77 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
ports:
- "5432:5432"
environment:
POSTGRES_USER: ${POSTGRES_USER:-drinktracker}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: ${POSTGRES_DB:-drinktracker}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U drinktracker -d drinktracker"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
restart: unless-stopped
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in .env}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in .env}
volumes:
- miniodata:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 5
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in .env}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in .env}
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $$MINIO_ACCESS_KEY $$MINIO_SECRET_KEY;
mc mb local/drink-images --ignore-existing;
mc anonymous set download local/drink-images;
exit 0;
"
app:
image: node:20-alpine
restart: unless-stopped
working_dir: /app
ports:
- "3000:3000"
volumes:
- .:/app
- app_node_modules:/app/node_modules
env_file:
- .env
environment:
DATABASE_URL: "postgresql://${POSTGRES_USER:-drinktracker}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-drinktracker}"
MINIO_ENDPOINT: "minio"
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
command: sh -c "npm install && npx prisma generate && npx prisma db push && npm run dev"
volumes:
pgdata:
miniodata:
app_node_modules:

21
next.config.mjs Normal file
View File

@@ -0,0 +1,21 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
images: {
remotePatterns: [
{
protocol: "http",
hostname: "localhost",
port: "9000",
pathname: "/drink-images/**",
},
{
protocol: "https",
hostname: "*.amazonaws.com",
pathname: "/**",
},
],
},
};
export default nextConfig;

8428
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "drinktracker-init",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"@auth/prisma-adapter": "^2.11.1",
"@aws-sdk/client-s3": "^3.1000.0",
"@prisma/client": "^6.19.2",
"@tanstack/react-query": "^5.90.21",
"@types/bcryptjs": "^2.4.6",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"next": "14.2.35",
"next-auth": "^5.0.0-beta.25",
"openai": "^6.25.0",
"prisma": "^6.19.2",
"react": "^18",
"react-dom": "^18",
"sharp": "^0.34.5",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

8
postcss.config.mjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@@ -0,0 +1,224 @@
-- CreateEnum
CREATE TYPE "DrinkType" AS ENUM ('BEER', 'WINE', 'COCKTAIL', 'SPIRIT', 'OTHER');
-- CreateEnum
CREATE TYPE "ScanStatus" AS ENUM ('UPLOADING', 'PROCESSING', 'COMPLETED', 'FAILED');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "UserApiKey" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"encryptedKey" TEXT NOT NULL,
"iv" TEXT NOT NULL,
"label" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserPreference" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"preferredStyles" TEXT[],
"avoidedStyles" TEXT[],
"minAbv" DOUBLE PRECISION,
"maxAbv" DOUBLE PRECISION,
"defaultProvider" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserPreference_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Drink" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "DrinkType" NOT NULL,
"subType" TEXT,
"brewery" TEXT,
"region" TEXT,
"abv" DOUBLE PRECISION,
"description" TEXT,
"imageUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Drink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Rating" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"drinkId" TEXT NOT NULL,
"score" INTEGER NOT NULL,
"notes" TEXT,
"wouldReorder" BOOLEAN NOT NULL DEFAULT false,
"location" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Rating_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MenuScan" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"imageUrl" TEXT NOT NULL,
"status" "ScanStatus" NOT NULL DEFAULT 'UPLOADING',
"aiProvider" TEXT,
"aiRawResponse" JSONB,
"errorMessage" TEXT,
"processedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MenuScan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MenuItem" (
"id" TEXT NOT NULL,
"scanId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "DrinkType" NOT NULL,
"subType" TEXT,
"brewery" TEXT,
"abv" DOUBLE PRECISION,
"price" TEXT,
"description" TEXT,
"matchedDrinkId" TEXT,
"userRating" INTEGER,
"aiRecommended" BOOLEAN NOT NULL DEFAULT false,
"aiReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MenuItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "UserApiKey_userId_provider_key" ON "UserApiKey"("userId", "provider");
-- CreateIndex
CREATE UNIQUE INDEX "UserPreference_userId_key" ON "UserPreference"("userId");
-- CreateIndex
CREATE INDEX "Drink_userId_idx" ON "Drink"("userId");
-- CreateIndex
CREATE INDEX "Drink_userId_type_idx" ON "Drink"("userId", "type");
-- CreateIndex
CREATE INDEX "Drink_userId_name_idx" ON "Drink"("userId", "name");
-- CreateIndex
CREATE INDEX "Rating_userId_idx" ON "Rating"("userId");
-- CreateIndex
CREATE INDEX "Rating_drinkId_idx" ON "Rating"("drinkId");
-- CreateIndex
CREATE INDEX "MenuScan_userId_idx" ON "MenuScan"("userId");
-- CreateIndex
CREATE INDEX "MenuItem_scanId_idx" ON "MenuItem"("scanId");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserApiKey" ADD CONSTRAINT "UserApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserPreference" ADD CONSTRAINT "UserPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Drink" ADD CONSTRAINT "Drink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_drinkId_fkey" FOREIGN KEY ("drinkId") REFERENCES "Drink"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuScan" ADD CONSTRAINT "MenuScan_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scanId_fkey" FOREIGN KEY ("scanId") REFERENCES "MenuScan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_matchedDrinkId_fkey" FOREIGN KEY ("matchedDrinkId") REFERENCES "Drink"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,54 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "password" TEXT;
-- CreateTable
CREATE TABLE "WishlistItem" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "DrinkType" NOT NULL,
"subType" TEXT,
"brewery" TEXT,
"abv" DOUBLE PRECISION,
"description" TEXT,
"notes" TEXT,
"source" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WishlistItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SharedList" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"listType" TEXT NOT NULL DEFAULT 'collection',
"isPublic" BOOLEAN NOT NULL DEFAULT true,
"drinkIds" TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SharedList_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WishlistItem_userId_idx" ON "WishlistItem"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "SharedList_slug_key" ON "SharedList"("slug");
-- CreateIndex
CREATE INDEX "SharedList_userId_idx" ON "SharedList"("userId");
-- CreateIndex
CREATE INDEX "SharedList_slug_idx" ON "SharedList"("slug");
-- AddForeignKey
ALTER TABLE "WishlistItem" ADD CONSTRAINT "WishlistItem_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SharedList" ADD CONSTRAINT "SharedList_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "SearchCache" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"queryHash" TEXT NOT NULL,
"query" TEXT NOT NULL,
"results" JSONB NOT NULL,
"provider" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SearchCache_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SearchCache_userId_idx" ON "SearchCache"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "SearchCache_userId_queryHash_provider_key" ON "SearchCache"("userId", "queryHash", "provider");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

251
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,251 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Auth.js Models ──────────────────────────────────────────────
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
password String? // hashed password for credentials auth
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
drinks Drink[]
ratings Rating[]
menuScans MenuScan[]
apiKeys UserApiKey[]
preferences UserPreference?
wishlistItems WishlistItem[]
sharedLists SharedList[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// ─── App Models ──────────────────────────────────────────────────
model UserApiKey {
id String @id @default(cuid())
userId String
provider String // "claude" | "openai"
encryptedKey String @db.Text
iv String // initialization vector for decryption
label String? // optional user-friendly label
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, provider])
}
model UserPreference {
id String @id @default(cuid())
userId String @unique
preferredStyles String[] // e.g., ["IPA", "Stout", "Pinot Noir"]
avoidedStyles String[] // e.g., ["Sour", "Light Lager"]
minAbv Float?
maxAbv Float?
defaultProvider String? // preferred AI provider
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum DrinkType {
BEER
WINE
COCKTAIL
SPIRIT
OTHER
}
model Drink {
id String @id @default(cuid())
userId String
name String
type DrinkType
subType String? // e.g., "IPA", "Stout", "Cabernet Sauvignon"
brewery String? // brewery or winery
region String?
abv Float?
description String? @db.Text
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ratings Rating[]
menuItems MenuItem[]
@@index([userId])
@@index([userId, type])
@@index([userId, name])
}
model Rating {
id String @id @default(cuid())
userId String
drinkId String
score Int // 1-5
notes String? @db.Text
wouldReorder Boolean @default(false)
location String? // where they tried it
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
drink Drink @relation(fields: [drinkId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([drinkId])
}
enum ScanStatus {
UPLOADING
PROCESSING
COMPLETED
FAILED
}
model MenuScan {
id String @id @default(cuid())
userId String
imageUrl String
status ScanStatus @default(UPLOADING)
aiProvider String? // which provider was used
aiRawResponse Json? // raw AI response for debugging
errorMessage String? @db.Text
processedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items MenuItem[]
@@index([userId])
}
model MenuItem {
id String @id @default(cuid())
scanId String
name String
type DrinkType
subType String?
brewery String?
abv Float?
price String?
description String? @db.Text
matchedDrinkId String?
userRating Int? // cached rating from matched drink
aiRecommended Boolean @default(false)
aiReason String? @db.Text // why AI recommends it
createdAt DateTime @default(now())
scan MenuScan @relation(fields: [scanId], references: [id], onDelete: Cascade)
matchedDrink Drink? @relation(fields: [matchedDrinkId], references: [id], onDelete: SetNull)
@@index([scanId])
}
// ─── Wishlist / Try Later ────────────────────────────────────────
model WishlistItem {
id String @id @default(cuid())
userId String
name String
type DrinkType
subType String?
brewery String?
abv Float?
description String? @db.Text
notes String? @db.Text // user's personal note ("saw at Joe's bar")
source String? // "scan", "ai_search", "manual"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
// ─── Search Cache ───────────────────────────────────────────────
model SearchCache {
id String @id @default(cuid())
userId String
queryHash String // normalized (lowercase, trimmed)
query String // original text
results Json // { drinks: [...] }
provider String // "claude" | "openai"
createdAt DateTime @default(now())
@@unique([userId, queryHash, provider])
@@index([userId])
}
// ─── Shared Lists ────────────────────────────────────────────────
model SharedList {
id String @id @default(cuid())
userId String
slug String @unique // public URL slug
title String
description String? @db.Text
listType String @default("collection") // "collection", "wishlist", "custom"
isPublic Boolean @default(true)
drinkIds String[] // ids of drinks to include (empty = all)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([slug])
}

0
public/.gitkeep Normal file
View File

22
public/manifest.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "DrinkTracker",
"short_name": "DrinkTracker",
"description": "Track, rate, and discover your favorite drinks with AI-powered menu scanning",
"start_url": "/dashboard",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ea580c",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

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

Binary file not shown.

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

@@ -0,0 +1,76 @@
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import type { WishlistCreate } from "@/lib/validators"
export interface WishlistItem {
id: string
userId: string
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType: string | null
brewery: string | null
abv: number | null
description: string | null
notes: string | null
source: string | null
createdAt: string
updatedAt: string
}
async function fetchWithError(url: string, options?: RequestInit) {
const res = await fetch(url, options)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || `Request failed with status ${res.status}`)
}
return res.json()
}
export function useWishlist() {
return useQuery<{ items: WishlistItem[] }>({
queryKey: ["wishlist"],
queryFn: () => fetchWithError("/api/wishlist"),
})
}
export function useAddToWishlist() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: WishlistCreate) =>
fetchWithError("/api/wishlist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wishlist"] })
},
})
}
export function useRemoveFromWishlist() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
fetchWithError(`/api/wishlist/${id}`, { method: "DELETE" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wishlist"] })
},
})
}
export function usePromoteWishlistItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
fetchWithError(`/api/wishlist/${id}`, { method: "POST" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wishlist"] })
queryClient.invalidateQueries({ queryKey: ["drinks"] })
},
})
}

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

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

View File

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

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

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

View File

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

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

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

Some files were not shown because too many files have changed in this diff Show More