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:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
*.md
|
||||
.claude
|
||||
26
.env.example
Normal file
26
.env.example
Normal 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
8
.eslintrc.json
Normal 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
42
.gitignore
vendored
Normal 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
45
Dockerfile
Normal 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
36
README.md
Normal 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
17
components.json
Normal 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
65
docker-compose.prod.yml
Normal 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
77
docker-compose.yml
Normal 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
21
next.config.mjs
Normal 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
8428
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
package.json
Normal file
43
package.json
Normal 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
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
224
prisma/migrations/20260228220749_init/migration.sql
Normal file
224
prisma/migrations/20260228220749_init/migration.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
251
prisma/schema.prisma
Normal 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
0
public/.gitkeep
Normal file
22
public/manifest.json
Normal file
22
public/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
164
src/app/(app)/dashboard/page.tsx
Normal file
164
src/app/(app)/dashboard/page.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
273
src/app/(app)/drinks/[id]/page.tsx
Normal file
273
src/app/(app)/drinks/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
284
src/app/(app)/drinks/page.tsx
Normal file
284
src/app/(app)/drinks/page.tsx
Normal 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
14
src/app/(app)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
154
src/app/(app)/rate/[drinkId]/page.tsx
Normal file
154
src/app/(app)/rate/[drinkId]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
201
src/app/(app)/scan/[id]/page.tsx
Normal file
201
src/app/(app)/scan/[id]/page.tsx
Normal 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'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'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
103
src/app/(app)/scan/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
339
src/app/(app)/settings/page.tsx
Normal file
339
src/app/(app)/settings/page.tsx
Normal 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é"
|
||||
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>
|
||||
)
|
||||
}
|
||||
146
src/app/(app)/wishlist/page.tsx
Normal file
146
src/app/(app)/wishlist/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
src/app/(auth)/login/page.tsx
Normal file
150
src/app/(auth)/login/page.tsx
Normal 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't have an account?{" "}
|
||||
<Link href="/register" className="text-primary underline-offset-4 hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
src/app/(auth)/register/page.tsx
Normal file
154
src/app/(auth)/register/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
src/app/api/ai/search/history/route.ts
Normal file
25
src/app/api/ai/search/history/route.ts
Normal 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 })
|
||||
}
|
||||
102
src/app/api/ai/search/route.ts
Normal file
102
src/app/api/ai/search/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth"
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
64
src/app/api/auth/register/route.ts
Normal file
64
src/app/api/auth/register/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
139
src/app/api/drinks/[id]/route.ts
Normal file
139
src/app/api/drinks/[id]/route.ts
Normal 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
140
src/app/api/drinks/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
164
src/app/api/ratings/[id]/route.ts
Normal file
164
src/app/api/ratings/[id]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
146
src/app/api/ratings/route.ts
Normal file
146
src/app/api/ratings/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
65
src/app/api/scan/[id]/route.ts
Normal file
65
src/app/api/scan/[id]/route.ts
Normal 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
178
src/app/api/scan/route.ts
Normal 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",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
130
src/app/api/settings/api-keys/route.ts
Normal file
130
src/app/api/settings/api-keys/route.ts
Normal 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 })
|
||||
}
|
||||
60
src/app/api/settings/backup/route.ts
Normal file
60
src/app/api/settings/backup/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
61
src/app/api/settings/preferences/route.ts
Normal file
61
src/app/api/settings/preferences/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
99
src/app/api/settings/restore/route.ts
Normal file
99
src/app/api/settings/restore/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
62
src/app/api/shared-lists/[id]/route.ts
Normal file
62
src/app/api/shared-lists/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
55
src/app/api/shared-lists/route.ts
Normal file
55
src/app/api/shared-lists/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
50
src/app/api/upload/route.ts
Normal file
50
src/app/api/upload/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
59
src/app/api/wishlist/[id]/route.ts
Normal file
59
src/app/api/wishlist/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
51
src/app/api/wishlist/route.ts
Normal file
51
src/app/api/wishlist/route.ts
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
src/app/fonts/GeistMonoVF.woff
Normal file
BIN
src/app/fonts/GeistMonoVF.woff
Normal file
Binary file not shown.
BIN
src/app/fonts/GeistVF.woff
Normal file
BIN
src/app/fonts/GeistVF.woff
Normal file
Binary file not shown.
59
src/app/globals.css
Normal file
59
src/app/globals.css
Normal 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
47
src/app/layout.tsx
Normal 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
5
src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function Home() {
|
||||
redirect("/dashboard")
|
||||
}
|
||||
171
src/app/share/[slug]/page.tsx
Normal file
171
src/app/share/[slug]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
src/components/drinks/add-to-wishlist-button.tsx
Normal file
70
src/components/drinks/add-to-wishlist-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
256
src/components/drinks/ai-drink-search.tsx
Normal file
256
src/components/drinks/ai-drink-search.tsx
Normal 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">
|
||||
“{activeQuery}”
|
||||
</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>
|
||||
)
|
||||
}
|
||||
88
src/components/drinks/drink-card.tsx
Normal file
88
src/components/drinks/drink-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
149
src/components/drinks/drink-detail-actions.tsx
Normal file
149
src/components/drinks/drink-detail-actions.tsx
Normal 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 “{drinkName}”? 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
74
src/components/drinks/drink-filters.tsx
Normal file
74
src/components/drinks/drink-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
175
src/components/drinks/drink-form.tsx
Normal file
175
src/components/drinks/drink-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
src/components/layout/bottom-nav.tsx
Normal file
43
src/components/layout/bottom-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
src/components/layout/header.tsx
Normal file
14
src/components/layout/header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
src/components/layout/sidebar.tsx
Normal file
91
src/components/layout/sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
src/components/providers.tsx
Normal file
27
src/components/providers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
src/components/ratings/rating-display.tsx
Normal file
69
src/components/ratings/rating-display.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
164
src/components/ratings/rating-form.tsx
Normal file
164
src/components/ratings/rating-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
122
src/components/ratings/star-rating.tsx
Normal file
122
src/components/ratings/star-rating.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
src/components/scan/camera-capture.tsx
Normal file
131
src/components/scan/camera-capture.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
170
src/components/scan/menu-item-card.tsx
Normal file
170
src/components/scan/menu-item-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
172
src/components/scan/photo-upload.tsx
Normal file
172
src/components/scan/photo-upload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
src/components/settings/backup-restore.tsx
Normal file
119
src/components/settings/backup-restore.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
240
src/components/settings/restore-dialog.tsx
Normal file
240
src/components/settings/restore-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
src/components/sharing/share-button.tsx
Normal file
20
src/components/sharing/share-button.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
189
src/components/sharing/share-dialog.tsx
Normal file
189
src/components/sharing/share-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
src/components/ui/badge.tsx
Normal file
35
src/components/ui/badge.tsx
Normal 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 }
|
||||
53
src/components/ui/button.tsx
Normal file
53
src/components/ui/button.tsx
Normal 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 }
|
||||
78
src/components/ui/card.tsx
Normal file
78
src/components/ui/card.tsx
Normal 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 }
|
||||
203
src/components/ui/dialog.tsx
Normal file
203
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
211
src/components/ui/dropdown-menu.tsx
Normal file
211
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
24
src/components/ui/input.tsx
Normal file
24
src/components/ui/input.tsx
Normal 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 }
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal 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 }
|
||||
49
src/components/ui/select.tsx
Normal file
49
src/components/ui/select.tsx
Normal 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 }
|
||||
29
src/components/ui/separator.tsx
Normal file
29
src/components/ui/separator.tsx
Normal 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 }
|
||||
16
src/components/ui/skeleton.tsx
Normal file
16
src/components/ui/skeleton.tsx
Normal 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 }
|
||||
23
src/components/ui/textarea.tsx
Normal file
23
src/components/ui/textarea.tsx
Normal 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
232
src/components/ui/toast.tsx
Normal 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
68
src/hooks/use-backup.ts
Normal 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
137
src/hooks/use-drinks.ts
Normal 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
151
src/hooks/use-ratings.ts
Normal 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
109
src/hooks/use-scan.ts
Normal 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"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
35
src/hooks/use-search-history.ts
Normal file
35
src/hooks/use-search-history.ts
Normal 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
76
src/hooks/use-wishlist.ts
Normal 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
209
src/lib/ai/base-provider.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
78
src/lib/ai/claude-provider.ts
Normal file
78
src/lib/ai/claude-provider.ts
Normal 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
291
src/lib/ai/menu-analyzer.ts
Normal 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)
|
||||
}
|
||||
81
src/lib/ai/openai-provider.ts
Normal file
81
src/lib/ai/openai-provider.ts
Normal 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
168
src/lib/ai/prompts.ts
Normal 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
Reference in New Issue
Block a user