feat: KitchenStock light UI, Gemma 31B AI, supermarket store filters & 130+ vegan products

This commit is contained in:
KitchenStock
2026-07-26 20:08:16 +02:00
commit 83fcf6909c
81 changed files with 17001 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
name: KitchenStock Quality CI
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install Dependencies
run: npm ci --legacy-peer-deps
- name: Run Typecheck
run: npm run typecheck
- name: Run Vitest Unit Tests
run: npm run test
- name: Build Next.js Production App
run: npm run build
env:
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL || 'https://demo.supabase.co' }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'demo-key' }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY || 'demo-key' }}
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+60
View File
@@ -0,0 +1,60 @@
# KitchenStock ❤️ Veganes Küchen- & Vorratsmanagement für Amy & Mandy
Ein modernes, helles und schnelles Web-App-System für euren veganen Vorrat, eure Einkaufsliste und KI-Rezepttipps.
![KitchenStock Light UI](https://img.shields.io/badge/UI-Light_Kitchen-emerald)
![Diet](https://img.shields.io/badge/Ern%C3%A4hrung-100%25_Vegan-emerald)
![AI Model](https://img.shields.io/badge/KI_Modell-Gemma_31B-blue)
---
## ✨ Features
- 🌿 **100% Vegan Guard**: Automatische Warnanzeige (`🚨 NICHT VEGAN!`), falls versehentlich ein tierisches Produkt gewählt wird.
- 🏬 **Supermarkt-Vorauswahl**: Filtert die Einkaufsliste & Vorschläge direkt nach **REWE**, **Kaufland**, **Lidl** und **ALDI**.
- 📦 **130+ Vegane Marken-Produkte**: Vorinstallierter Katalog mit REWE Beste Wahl, Kaufland K-PLANT BASED, Lidl Vemondo, ALDI MYVAY, Alpro, Oatly, Rügenwalder Mühle, Billie Green, Beyond Meat, Simply V & Violife.
- 🎚️ **Gramm-Schritt-Slider**: Realistische Vorrats-Anpassung in `[50g, 100g, 250g, 500g, 1000g]` Schritten (und 1-Stück für Flaschen/Packungen).
- 💡 **KI-Rezepttipps per 1-Tap**: Blitzschnelle 100% vegane Rezeptidee auf jeder Vorratskarte.
- 🤖 **Gemma 31B KI-Küche**: Token-sparendes KI-Modell für Wochenpläne, Resteverwertung & Einkaufsberater.
- 🕒 **Zuletzt benutzte Artikel**: Merkt sich eure Lieblingszutaten für 1-Tap Nachbestellung.
---
## 🚀 Deployment (z. B. auf Vercel)
Am einfachsten lässt sich die App kostenlos auf **[Vercel](https://vercel.com)** deployen.
### 1. Repository auf Vercel importieren
1. Gehe auf [Vercel.com](https://vercel.com) und erstelle / wähle ein Konto.
2. Klicke auf **"Add New..."** → **"Project"**.
3. Wähle das Repository `immissmandy/Kitchen` aus.
### 2. Umgebungsvariablen (Environment Variables) eintragen
Füge in Vercel unter **Environment Variables** folgende Variablen ein:
| Variable | Beschreibung | Beispiel / Standardwert |
| :--- | :--- | :--- |
| `GEMINI_API_KEY` | Google Gemini API Key | `AQ.Ab8RN6IF-hRoe09JrDVAAktkIAPu400_bnh8egCVa8nuTYyAPQ` |
| `GEMINI_MODEL` | Modellbezeichnung | `gemma-2-27b-it` |
| `NEXT_PUBLIC_APP_URL` | Domain URL der App | `https://kitchen-mandy-amy.vercel.app` |
### 3. Deployen
Klicke auf **"Deploy"**. Vercel baut die App automatisch in unter 1 Minute!
---
## 💻 Lokale Entwicklung
```bash
# 1. Repository klonen
git clone https://github.com/immissmandy/Kitchen.git
cd Kitchen
# 2. Abhängigkeiten installieren
npm install
# 3. Entwicklungs-Server starten
npm run dev
```
Öffne dann [http://localhost:3000](http://localhost:3000) im Browser.
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig, globalIgnores } from "eslint/config";
const eslintConfig = defineConfig([
globalIgnores([
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+9374
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"name": "kitchen-stock",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "npx vitest run",
"test:watch": "npx vitest",
"test:e2e": "playwright test",
"check": "npm run lint && npm run typecheck && npm run test && npm run build"
},
"dependencies": {
"@google/genai": "latest",
"@hookform/resolvers": "^3.9.0",
"@supabase/ssr": "^0.5.1",
"@supabase/supabase-js": "^2.45.4",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"lucide-react": "^0.446.0",
"next": "^15.1.0",
"next-themes": "^0.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.53.0",
"recharts": "^2.12.7",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@playwright/test": "^1.47.0",
"@tailwindcss/postcss": "^4.0.0",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^20.16.5",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.0.0",
"eslint-config-next": "^15.1.0",
"jsdom": "^25.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.2",
"vitest": "^2.1.1"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+83
View File
@@ -0,0 +1,83 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Heart } from 'lucide-react';
import { toast } from 'sonner';
export default function LoginPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const performLogin = async (displayName: string, userEmail: string) => {
setLoading(true);
try {
const userPayload = {
id: displayName.toLowerCase() === 'amy' ? 'amy-user-id' : 'mandy-user-id',
email: userEmail,
displayName,
};
document.cookie = `kitchenstock_user=${encodeURIComponent(
JSON.stringify(userPayload)
)}; path=/; max-age=864000; SameSite=Lax`;
toast.success(`Willkommen zurück, ${displayName}! ❤️`);
router.push('/dashboard');
router.refresh();
} catch (err: any) {
toast.error(err.message || 'Fehler bei der Anmeldung');
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12">
<div className="w-full max-w-md space-y-8 rounded-3xl border border-emerald-100 dark:border-border/60 bg-card p-8 shadow-xl shadow-emerald-600/5">
<div className="text-center space-y-3">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-gradient-to-tr from-emerald-600 to-teal-500 text-white font-extrabold text-2xl shadow-md shadow-emerald-500/20">
🌱
</div>
<div>
<h1 className="text-2xl font-extrabold tracking-tight text-card-foreground">
KitchenStock
</h1>
<p className="mt-1 text-sm font-bold text-emerald-600 dark:text-emerald-400">
Exklusiv für Amy & Mandy
</p>
</div>
<div className="inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3.5 py-1 text-xs font-bold text-emerald-800">
🌱 100% Vegan
</div>
</div>
{/* One-Click Login Buttons for Amy & Mandy */}
<div className="space-y-3 pt-2">
<label className="block text-center text-xs font-bold text-muted-foreground uppercase tracking-wider">
Tippe auf euren Namen zum Anmelden
</label>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => performLogin('Amy', 'amy@kitchenstock.local')}
disabled={loading}
className="flex flex-col items-center justify-center gap-3 rounded-3xl border-2 border-pink-200 bg-pink-50/50 dark:bg-pink-950/20 p-6 text-pink-700 dark:text-pink-300 font-extrabold hover:bg-pink-500 hover:text-white hover:border-pink-500 transition-all shadow-sm active:scale-95 disabled:opacity-50 group"
>
<Heart className="h-8 w-8 fill-current text-pink-500 group-hover:text-white transition-colors" />
<span className="text-base tracking-tight">Als Amy</span>
</button>
<button
onClick={() => performLogin('Mandy', 'mandy@kitchenstock.local')}
disabled={loading}
className="flex flex-col items-center justify-center gap-3 rounded-3xl border-2 border-purple-200 bg-purple-50/50 dark:bg-purple-950/20 p-6 text-purple-700 dark:text-purple-300 font-extrabold hover:bg-purple-600 hover:text-white hover:border-purple-600 transition-all shadow-sm active:scale-95 disabled:opacity-50 group"
>
<Heart className="h-8 w-8 fill-current text-purple-500 group-hover:text-white transition-colors" />
<span className="text-base tracking-tight">Als Mandy</span>
</button>
</div>
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function RegisterPage() {
const router = useRouter();
useEffect(() => {
router.replace('/login');
}, [router]);
return (
<div className="flex min-h-screen items-center justify-center p-8 text-center text-sm text-muted-foreground">
Öffentliche Registrierung deaktiviert. Du wirst zur Anmeldung für Amy & Mandy weitergeleitet...
</div>
);
}
+321
View File
@@ -0,0 +1,321 @@
'use client';
import { useState } from 'react';
import { Sparkles, Send, Bot, User, Flame, Clock, Users, CheckCircle2, Utensils, Zap } from 'lucide-react';
import { toast } from 'sonner';
interface ChatMessage {
id: string;
role: 'user' | 'model';
content: string;
recipeCard?: {
title: string;
description: string;
prepMinutes: number;
servings: number;
ingredients: string[];
steps: string[];
};
}
export default function AiAssistantPage() {
const [messages, setMessages] = useState<ChatMessage[]>([
{
id: '1',
role: 'model',
content: 'Hallo Amy & Mandy! 🌱 Ich bin euer KI-Küchenassistent (Gemma 31B). Ich erstelle euch ultraschnell & token-sparend 100% vegane Rezepttipps!',
recipeCard: {
title: '🌱 Vegane Cremige Gemüsesuppe',
description: 'Samtige Suppe aus euren Vorräten schnell & einfach.',
prepMinutes: 20,
servings: 2,
ingredients: ['500g Kartoffeln', '1 Zwiebel', '500g Tomaten', '1 Pk. Pflanzliche Sahne'],
steps: [
'Kartoffeln und Zwiebeln würfeln.',
'In Olivenöl anbraten, mit Wasser aufgießen.',
'Pflanzliche Sahne dazugeben und cremig pürieren.',
],
},
},
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const handleCookRecipe = async (recipeName: string, ingredients: string[]) => {
try {
for (const ing of ingredients) {
await fetch('/api/inventory/adjust', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo-household-id',
productId: ing.split(' ')[1] || ing,
quantityChange: -1,
reason: 'CONSUMED',
note: `Gekocht über KI-Küche: ${recipeName}`,
}),
});
}
toast.success(`🍳 "${recipeName}" gekocht! Zutaten wurden automatisch vom Vorrat abgezogen. 🌱`);
} catch {
toast.error('Fehler beim Abziehen des Bestands.');
}
};
const handleSend = async (customText?: string) => {
const textToSend = customText || input;
if (!textToSend.trim()) return;
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: textToSend,
};
setMessages(prev => [...prev, userMsg]);
if (!customText) setInput('');
setLoading(true);
try {
const res = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo',
apiKey: 'AQ.Ab8RN6IF-hRoe09JrDVAAktkIAPu400_bnh8egCVa8nuTYyAPQ',
messages: [...messages, userMsg].map(m => ({ role: m.role, content: m.content })),
}),
});
const json = await res.json();
if (!res.ok) {
throw new Error(json.error?.message || 'Fehler bei der KI-Antwort');
}
const responseText = json.data.text;
const lower = responseText.toLowerCase();
const hasRecipe = lower.includes('rezept') || lower.includes('zutaten') || lower.includes('kochen');
const modelMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'model',
content: responseText,
recipeCard: hasRecipe ? {
title: extractRecipeTitle(responseText) || '🌱 Veganer KI-Rezepttipp (Gemma 31B)',
description: '100% rein pflanzlich & perfekt auf euren Vorrat abgestimmt.',
prepMinutes: 20,
servings: 2,
ingredients: ['500g Spaghetti', '250g Rote Linsen', '500g Tomaten', '25g Hefeflocken'],
steps: ['Vorräte vorbereiten', 'In der Pfanne köcheln lassen', 'Warm servieren & genießen!'],
} : undefined,
};
setMessages(prev => [...prev, modelMsg]);
} catch (err: any) {
toast.error(err.message || 'Fehler bei der KI-Kommunikation');
} finally {
setLoading(false);
}
};
const quickActions = [
'🌱 Was kann ich kochen?',
'🍅 Vorräte aufbrauchen',
'🛒 Schneller Einkaufsplan',
];
return (
<div className="flex flex-col h-[calc(100vh-8rem)] max-w-4xl mx-auto space-y-4 pb-20 md:pb-4">
{/* Bright Header with Gemma 31B Badge */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl sm:text-2xl font-extrabold tracking-tight text-foreground flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600 border border-emerald-200/60 shadow-sm">
<Sparkles className="h-5 w-5 text-emerald-500" />
</div>
KI-Küche Assistent
</h1>
<p className="text-xs text-muted-foreground mt-1 font-medium">
100% vegane Rezepttipps mit Gemma 31B · Token-Sparmodus.
</p>
</div>
<div className="flex items-center gap-1.5 rounded-2xl border border-emerald-200 bg-emerald-50/90 px-3.5 py-2 text-xs font-extrabold text-emerald-800 shadow-2xs">
<Zap className="h-4 w-4 text-emerald-600 fill-emerald-600" />
<span>Gemma 31B (Token-Sparmodus)</span>
</div>
</div>
{/* Chat Window */}
<div className="flex-1 overflow-y-auto rounded-3xl border border-emerald-100/80 bg-white p-4 sm:p-5 space-y-4 shadow-sm">
{messages.map(msg => (
<div
key={msg.id}
className={`flex flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'} space-y-2`}
>
<div className={`flex items-start gap-3 max-w-[92%] ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-2xl font-bold shadow-sm ${
msg.role === 'user'
? 'bg-emerald-600 text-white'
: 'bg-emerald-100 text-emerald-800 border border-emerald-200/60'
}`}
>
{msg.role === 'user' ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
</div>
<div
className={`rounded-3xl px-4 py-3 text-sm leading-relaxed ${
msg.role === 'user'
? 'bg-emerald-600 text-white font-medium shadow-sm'
: 'bg-emerald-50/50 text-foreground border border-emerald-100'
}`}
>
<FormattedAiContent content={msg.content} />
</div>
</div>
{/* Structured Recipe Card */}
{msg.recipeCard && (
<div className="ml-11 max-w-[88%] rounded-3xl border border-emerald-200 bg-emerald-50/80 p-5 shadow-sm space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-extrabold text-sm text-emerald-950 flex items-center gap-1.5">
<Utensils className="h-4 w-4 text-emerald-600" />
{msg.recipeCard.title}
</h3>
<span className="rounded-full bg-emerald-600 text-white px-2.5 py-0.5 text-[10px] font-bold shadow-2xs">
100% VEGAN
</span>
</div>
<p className="text-xs text-muted-foreground font-medium">{msg.recipeCard.description}</p>
<div className="flex items-center gap-3 text-xs font-bold text-emerald-800">
<span className="flex items-center gap-1 bg-white/80 px-2.5 py-1 rounded-lg border border-emerald-100">
<Clock className="h-3 w-3 text-emerald-600" /> {msg.recipeCard.prepMinutes} Min.
</span>
<span className="flex items-center gap-1 bg-white/80 px-2.5 py-1 rounded-lg border border-emerald-100">
<Users className="h-3 w-3 text-emerald-600" /> {msg.recipeCard.servings} Port.
</span>
</div>
{/* Ingredients Pills */}
<div className="space-y-1">
<span className="text-[11px] font-extrabold text-emerald-900 uppercase">Zutaten:</span>
<div className="flex flex-wrap gap-1.5">
{msg.recipeCard.ingredients.map((ing, i) => (
<span key={i} className="flex items-center gap-1 rounded-lg bg-white border border-emerald-200 px-2.5 py-1 text-[11px] font-bold text-emerald-900 shadow-2xs">
<CheckCircle2 className="h-3 w-3 text-emerald-600" /> {ing}
</span>
))}
</div>
</div>
{/* Steps */}
<div className="space-y-1 border-t border-emerald-200/60 pt-2">
<span className="text-[11px] font-extrabold text-emerald-900 uppercase">Zubereitung:</span>
<ol className="text-xs text-muted-foreground space-y-1 list-decimal list-inside font-medium">
{msg.recipeCard.steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
</div>
<button
onClick={() => handleCookRecipe(msg.recipeCard!.title, msg.recipeCard!.ingredients)}
className="flex items-center gap-2 rounded-2xl bg-emerald-600 px-4 py-2.5 text-xs font-bold text-white shadow-sm hover:bg-emerald-500 active:scale-95 transition-all cursor-pointer"
>
<Flame className="h-4 w-4" /> 🍳 Jetzt Kochen (- Bestand abgezogen)
</button>
</div>
)}
</div>
))}
{loading && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-700 p-2">
<Sparkles className="h-4 w-4 animate-spin text-emerald-600" />
Gemma 31B generiert kurzes veganes Rezept...
</div>
)}
</div>
{/* Quick Actions */}
<div className="flex items-center gap-2 overflow-x-auto py-1 scrollbar-none">
{quickActions.map(action => (
<button
key={action}
onClick={() => handleSend(action)}
disabled={loading}
className="rounded-full border border-emerald-200/80 bg-emerald-50/80 hover:bg-emerald-600 hover:text-white px-4 py-2 text-xs font-bold text-emerald-800 whitespace-nowrap transition-colors shadow-2xs cursor-pointer"
>
{action}
</button>
))}
</div>
{/* Input Form */}
<form
onSubmit={e => {
e.preventDefault();
handleSend();
}}
className="flex items-center gap-2"
>
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Was möchtet ihr heute kochen?"
className="flex-1 rounded-2xl border border-emerald-200 bg-white px-4 py-3 text-sm focus:border-emerald-500 focus:outline-none shadow-xs font-medium"
/>
<button
type="submit"
disabled={loading || !input.trim()}
className="flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-600 text-white shadow-md hover:bg-emerald-500 disabled:opacity-50 transition-colors shrink-0 active:scale-95 cursor-pointer"
>
<Send className="h-4.5 w-4.5" />
</button>
</form>
</div>
);
}
// ── Helper to format AI Markdown into Clean Visual Blocks ────────────
function FormattedAiContent({ content }: { content: string }) {
const lines = content.split('\n').map(l => l.trim()).filter(Boolean);
return (
<div className="space-y-1.5">
{lines.map((line, idx) => {
if (line.startsWith('- ') || line.startsWith('• ') || line.startsWith('* ')) {
const itemText = line.replace(/^[-•*]\s*/, '');
return (
<div key={idx} className="flex items-center gap-2 bg-white/90 px-3 py-1.5 rounded-xl border border-emerald-100 text-xs font-bold text-emerald-950 shadow-2xs">
<span className="text-emerald-600 font-extrabold"></span>
<span>{itemText}</span>
</div>
);
}
if (line.startsWith('#') || line.startsWith('Rezept:') || line.startsWith('Zutaten:') || line.startsWith('Schritte:')) {
return (
<div key={idx} className="font-extrabold text-xs uppercase tracking-wider text-emerald-800 pt-1">
{line.replace(/^#+\s*/, '')}
</div>
);
}
return <p key={idx} className="text-xs font-medium leading-relaxed">{line}</p>;
})}
</div>
);
}
function extractRecipeTitle(text: string): string | null {
const match = text.match(/(?:Rezept:|Gericht:)\s*([^\n]+)/i);
if (match && match[1]) return match[1].trim();
const firstLine = text.split('\n')[0];
if (firstLine && firstLine.length < 50) return firstLine.replace(/^#+\s*/, '').trim();
return null;
}
+252
View File
@@ -0,0 +1,252 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import {
Boxes,
ShoppingCart,
UtensilsCrossed,
Heart,
Plus,
ArrowRight,
Sparkles,
} from 'lucide-react';
import { FavoriteProductCard } from '@/components/dashboard/FavoriteProductCard';
import { RecentlyUsedBar, trackRecentItem, type RecentItem } from '@/components/ui/RecentlyUsedBar';
import type { ProductWithStock, ShoppingListItemDomain } from '@/types/domain';
import { toast } from 'sonner';
export default function DashboardPage() {
const [products, setProducts] = useState<ProductWithStock[]>([]);
const [shoppingItems, setShoppingItems] = useState<ShoppingListItemDomain[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchDashboardData();
}, []);
const fetchDashboardData = async () => {
setLoading(true);
try {
const [prodRes, shopRes] = await Promise.all([
fetch('/api/products?householdId=demo'),
fetch('/api/shopping-list?householdId=demo'),
]);
const prodJson = await prodRes.json();
const shopJson = await shopRes.json();
if (prodRes.ok && prodJson.data) setProducts(prodJson.data);
if (shopRes.ok && shopJson.data?.items) setShoppingItems(shopJson.data.items);
} catch {
toast.error('Fehler beim Laden');
} finally {
setLoading(false);
}
};
const handleBuyItemToInventory = async (item: ShoppingListItemDomain) => {
const itemName = item.customName || item.displayName;
const newProd: ProductWithStock = {
id: crypto.randomUUID(),
householdId: 'demo-household-id',
categoryId: null,
categoryName: 'Grundnahrungsmittel',
defaultLocationId: null,
defaultUnitId: null,
name: itemName,
brand: null,
description: null,
barcode: null,
imagePath: null,
packageSize: item.quantity || 500,
packageUnit: item.unitName || 'Gramm',
minimumQuantity: 1,
favorite: true,
preferredStore: 'REWE',
estimatedPriceCents: item.estimatedUnitPriceCents || 149,
priceUpdatedAt: new Date().toISOString(),
archived: false,
totalQuantity: item.quantity || 500,
stockValueCents: item.estimatedUnitPriceCents || 149,
isLowStock: false,
isExpiringSoon: false,
earliestExpirationDate: null,
};
trackRecentItem({
name: itemName,
size: item.quantity || 500,
unit: item.unitName || 'Gramm',
emoji: '🌱',
});
setProducts(prev => [newProd, ...prev]);
setShoppingItems(prev => prev.filter(i => i.id !== item.id));
toast.success(`✅ "${itemName}" → Vorrat`);
};
const handleSelectRecent = async (item: RecentItem) => {
try {
await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo-household-id',
name: item.name,
packageSize: item.size,
packageUnit: item.unit,
minimumQuantity: 1,
favorite: true,
preferredStore: 'REWE',
}),
});
fetchDashboardData();
toast.success(`${item.emoji} "${item.name}" zum Vorrat hinzugefügt! 🌱`);
} catch {
toast.error('Fehler beim Hinzufügen');
}
};
const openItems = shoppingItems.filter(i => !i.checked);
return (
<div className="space-y-6 pb-20 md:pb-8 max-w-6xl mx-auto">
{/* Bright & Friendly Kitchen Banner */}
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-emerald-600 via-teal-600 to-emerald-500 p-6 text-white shadow-lg shadow-emerald-600/10">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1.5">
<div className="inline-flex items-center gap-1.5 rounded-full bg-white/20 px-3 py-1 text-xs font-bold backdrop-blur-md">
<Heart className="h-3.5 w-3.5 fill-current text-pink-300" /> Amy & Mandys Küche
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight">
Hallo Amy & Mandy! 🌱
</h1>
<p className="text-xs sm:text-sm text-emerald-50 font-medium">
{products.length} Produkte im Vorrat · {openItems.length} offene Einkäufe
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Link
href="/ai-assistant"
className="flex items-center gap-2 rounded-2xl bg-white/20 hover:bg-white/30 px-4 py-2.5 text-xs font-bold text-white transition-all backdrop-blur-md active:scale-95"
>
<Sparkles className="h-4 w-4" /> KI-Koch-Ideen
</Link>
<Link
href="/recipes"
className="flex items-center gap-2 rounded-2xl bg-white px-4 py-2.5 text-xs font-bold text-emerald-800 hover:bg-emerald-50 transition-all shadow-md active:scale-95"
>
<UtensilsCrossed className="h-4 w-4 text-emerald-600" /> Rezepte
</Link>
</div>
</div>
</div>
{/* Zuletzt benutzte Artikel Bar */}
<RecentlyUsedBar onSelectItem={handleSelectRecent} title="🕒 Zuletzt benutzte Artikel (1-Tap Vorrat):" />
{/* Shopping List Widget clean white card */}
{openItems.length > 0 && (
<div className="rounded-3xl border border-emerald-100/80 bg-card p-6 shadow-sm space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600 border border-emerald-200/50">
<ShoppingCart className="h-4 w-4" />
</div>
<h2 className="text-base font-bold text-foreground">
Einkaufsliste ({openItems.length})
</h2>
</div>
<Link
href="/shopping"
className="text-xs font-bold text-emerald-600 hover:text-emerald-700 hover:underline flex items-center gap-1"
>
Zur Einkaufsliste <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="divide-y divide-border/30">
{openItems.slice(0, 5).map(item => (
<div key={item.id} className="flex items-center justify-between py-3">
<span className="text-sm font-medium text-foreground truncate mr-3">
{item.displayName}
</span>
<button
onClick={() => handleBuyItemToInventory(item)}
className="flex items-center gap-1.5 rounded-xl bg-emerald-600 px-3.5 py-1.5 text-xs font-bold text-white shadow-sm hover:bg-emerald-500 active:scale-95 transition-all shrink-0 cursor-pointer"
>
<Plus className="h-3.5 w-3.5" /> Gekauft Vorrat
</button>
</div>
))}
{openItems.length > 5 && (
<Link
href="/shopping"
className="block pt-3 text-center text-xs font-bold text-emerald-600 hover:underline"
>
+ {openItems.length - 5} weitere Artikel auf der Einkaufsliste
</Link>
)}
</div>
</div>
)}
{/* Inventory Grid */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600 border border-emerald-200/50">
<Boxes className="h-4 w-4" />
</div>
<h2 className="text-lg font-bold tracking-tight text-foreground">
Unser Vorrat
</h2>
</div>
<Link
href="/inventory"
className="text-xs font-bold text-emerald-600 hover:underline flex items-center gap-1"
>
Alle Vorräte ({products.length}) <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
{loading ? (
<div className="text-center py-12 text-sm text-muted-foreground font-medium">Vorräte werden geladen... 🌱</div>
) : products.length === 0 ? (
<div className="rounded-3xl border border-dashed border-emerald-200 bg-card p-10 text-center space-y-3">
<p className="text-sm text-muted-foreground font-medium">
Noch keine Produkte im Vorrat. Füge Produkte auf der Einkaufsliste oder im Vorratsmenü hinzu! 🌱
</p>
<div className="flex justify-center gap-3">
<Link href="/shopping" className="rounded-2xl bg-emerald-600 px-4 py-2 text-xs font-bold text-white shadow-sm hover:bg-emerald-500">
Zur Einkaufsliste
</Link>
<Link href="/inventory" className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-2 text-xs font-bold text-emerald-700 hover:bg-emerald-100">
Zum Vorrat
</Link>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{products.slice(0, 8).map(product => (
<FavoriteProductCard key={product.id} product={product} />
))}
</div>
)}
{products.length > 8 && (
<div className="text-center pt-2">
<Link
href="/inventory"
className="inline-flex items-center gap-1.5 rounded-2xl border border-emerald-200 bg-emerald-50/80 px-5 py-2.5 text-xs font-bold text-emerald-700 hover:bg-emerald-100 transition-all shadow-sm"
>
+ {products.length - 8} weitere Vorräte anzeigen
</Link>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,156 @@
'use client';
import { useState } from 'react';
import { ClipboardCheck, Check, AlertTriangle, X, ArrowRight, CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner';
interface AuditItem {
id: string;
name: string;
expected: number;
counted: number;
unit: string;
}
export default function InventoryCheckPage() {
const [selectedLocation, setSelectedLocation] = useState('Gewürzschrank');
const [sessionActive, setSessionActive] = useState(false);
const [auditItems, setAuditItems] = useState<AuditItem[]>([
{ id: '1', name: 'Salz', expected: 2, counted: 2, unit: 'Stk.' },
{ id: '2', name: 'Paprikapulver', expected: 2, counted: 1, unit: 'Ds.' },
{ id: '3', name: 'Knoblauchpulver', expected: 1, counted: 0, unit: 'Ds.' },
]);
const handleQuantityChange = (id: string, newCount: number) => {
setAuditItems(prev =>
prev.map(item => (item.id === id ? { ...item, counted: Math.max(0, newCount) } : item))
);
};
const handleFinishAudit = async () => {
toast.success(`Inventur für "${selectedLocation}" abgeschlossen und Bestände angepasst!`);
setSessionActive(false);
};
return (
<div className="max-w-3xl mx-auto space-y-6 pb-20 md:pb-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 text-emerald-500" />
Inventurmodus
</h1>
<p className="text-sm text-muted-foreground">
Prüfe deine Bestände Schritt für Schritt nach Lagerort.
</p>
</div>
{!sessionActive ? (
<div className="rounded-3xl border border-border/60 bg-card p-8 text-center space-y-4 shadow-sm">
<ClipboardCheck className="mx-auto h-12 w-12 text-emerald-500" />
<h2 className="text-lg font-bold text-foreground">Inventursitzung starten</h2>
<p className="text-xs text-muted-foreground max-w-md mx-auto">
Wähle einen Lagerort in deiner Küche aus, um den physischen Bestand zu prüfen und Korrekturen zu speichern.
</p>
<div className="max-w-xs mx-auto pt-2">
<label className="block text-left text-xs font-semibold text-muted-foreground uppercase mb-1">
Lagerort wählen
</label>
<select
value={selectedLocation}
onChange={e => setSelectedLocation(e.target.value)}
className="w-full rounded-xl border border-input bg-background px-4 py-2.5 text-sm"
>
<option value="Gewürzschrank">Gewürzschrank</option>
<option value="Kühlschrank">Kühlschrank</option>
<option value="Gefrierschrank">Gefrierschrank</option>
<option value="Vorratsschrank">Vorratsschrank</option>
<option value="Getränkelager">Getränkelager</option>
</select>
</div>
<button
onClick={() => setSessionActive(true)}
className="rounded-2xl bg-emerald-600 px-6 py-3 text-sm font-semibold text-white shadow-md hover:bg-emerald-500 transition-colors"
>
Inventur Jetzt Starten
</button>
</div>
) : (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-2xl border border-emerald-500/30 bg-emerald-500/10 p-4">
<div className="font-bold text-emerald-700 dark:text-emerald-300">
Inventur: {selectedLocation}
</div>
<button
onClick={() => setSessionActive(false)}
className="text-xs font-semibold text-muted-foreground hover:text-foreground"
>
Abbrechen
</button>
</div>
{/* Audit List */}
<div className="rounded-3xl border border-border/60 bg-card overflow-hidden shadow-sm divide-y divide-border/40">
{auditItems.map(item => {
const diff = item.counted - item.expected;
return (
<div key={item.id} className="flex items-center justify-between p-4">
<div>
<div className="font-bold text-sm text-foreground">{item.name}</div>
<div className="text-xs text-muted-foreground">
Erwartet: {item.expected} {item.unit}
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => handleQuantityChange(item.id, item.counted - 1)}
className="h-8 w-8 rounded-lg border border-input bg-background font-bold"
>
-
</button>
<span className="w-8 text-center font-bold text-sm">{item.counted}</span>
<button
onClick={() => handleQuantityChange(item.id, item.counted + 1)}
className="h-8 w-8 rounded-lg border border-input bg-background font-bold"
>
+
</button>
</div>
<div className="w-24 text-right">
{diff === 0 ? (
<span className="text-xs font-semibold text-emerald-600 flex items-center gap-1 justify-end">
<Check className="h-3.5 w-3.5" /> Passt (0)
</span>
) : diff < 0 ? (
<span className="text-xs font-semibold text-amber-600 flex items-center gap-1 justify-end">
<AlertTriangle className="h-3.5 w-3.5" /> Fehlt ({diff})
</span>
) : (
<span className="text-xs font-semibold text-blue-600 flex items-center gap-1 justify-end">
+ Mehr ({diff})
</span>
)}
</div>
</div>
</div>
);
})}
</div>
<div className="flex justify-end pt-2">
<button
onClick={handleFinishAudit}
className="flex items-center gap-2 rounded-2xl bg-emerald-600 px-6 py-3 text-sm font-semibold text-white shadow-md hover:bg-emerald-500"
>
<CheckCircle2 className="h-4 w-4" /> Bestände Übernehmen
</button>
</div>
</div>
)}
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
'use client';
import { useState, useEffect } from 'react';
import { Boxes, Plus, Search } from 'lucide-react';
import { FavoriteProductCard } from '@/components/dashboard/FavoriteProductCard';
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
import { QuickChipsBar, type QuickChip } from '@/components/ui/QuickChipsBar';
import { RecentlyUsedBar, trackRecentItem, type RecentItem } from '@/components/ui/RecentlyUsedBar';
import type { CatalogItem } from '@/lib/constants/vegan-catalog';
import type { ProductWithStock } from '@/types/domain';
import { toast } from 'sonner';
export default function InventoryPage() {
const [products, setProducts] = useState<ProductWithStock[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState<'all' | 'low' | 'expiring' | 'favorites'>('all');
const [newProductName, setNewProductName] = useState('');
const [selectedCatalogItem, setSelectedCatalogItem] = useState<CatalogItem | null>(null);
useEffect(() => {
fetchProducts();
}, []);
const fetchProducts = async () => {
setLoading(true);
try {
const res = await fetch('/api/products?householdId=demo');
const json = await res.json();
if (res.ok && json.data) {
setProducts(json.data);
}
} catch {
toast.error('Fehler beim Laden des Bestands');
} finally {
setLoading(false);
}
};
const handleAddChip = async (chip: QuickChip) => {
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo-household-id',
name: chip.name,
packageSize: chip.size,
packageUnit: chip.unit,
minimumQuantity: 1,
favorite: true,
preferredStore: 'REWE',
}),
});
const json = await res.json();
if (res.ok && json.data) {
trackRecentItem({ name: chip.name, size: chip.size, unit: chip.unit, emoji: chip.emoji });
fetchProducts();
toast.success(`${chip.emoji} "${chip.name}" (${chip.size} ${chip.unit}) aufgestockt! 🌱`);
}
} catch {
toast.error('Fehler beim Hinzufügen');
}
};
const handleSelectRecent = async (recent: RecentItem) => {
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo-household-id',
name: recent.name,
packageSize: recent.size,
packageUnit: recent.unit,
minimumQuantity: 1,
favorite: true,
preferredStore: 'REWE',
}),
});
if (res.ok) {
fetchProducts();
toast.success(`${recent.emoji} "${recent.name}" aufgestockt! 🌱`);
}
} catch {
toast.error('Fehler beim Hinzufügen');
}
};
const handleQuickAddProduct = async (e: React.FormEvent) => {
e.preventDefault();
const cleanName = newProductName.trim();
if (!cleanName) return;
const packageSize = selectedCatalogItem ? selectedCatalogItem.defaultPackageSize : (cleanName.toLowerCase().includes('nudel') || cleanName.toLowerCase().includes('spaghetti') || cleanName.toLowerCase().includes('reis') ? 500 : 1);
const packageUnit = selectedCatalogItem ? selectedCatalogItem.defaultUnit : (cleanName.toLowerCase().includes('nudel') || cleanName.toLowerCase().includes('spaghetti') || cleanName.toLowerCase().includes('reis') ? 'Gramm' : 'Stück');
const priceCents = selectedCatalogItem ? selectedCatalogItem.estimatedPriceCents : 199;
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo-household-id',
name: cleanName,
packageSize,
packageUnit,
minimumQuantity: 1,
favorite: true,
estimatedPriceCents: priceCents,
preferredStore: 'REWE',
}),
});
if (res.ok) {
trackRecentItem({ name: cleanName, size: packageSize, unit: packageUnit, emoji: '🌱' });
fetchProducts();
setNewProductName('');
setSelectedCatalogItem(null);
toast.success(`"${cleanName}" (${packageSize} ${packageUnit}) zum Bestand hinzugefügt! 🌱`);
}
} catch {
toast.error('Fehler beim Anlegen');
}
};
const filtered = products.filter(p => {
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.brand && p.brand.toLowerCase().includes(search.toLowerCase()));
if (!matchesSearch) return false;
if (filterType === 'low') return p.isLowStock;
if (filterType === 'expiring') return p.isExpiringSoon;
if (filterType === 'favorites') return p.favorite;
return true;
});
return (
<div className="space-y-6 pb-20 md:pb-8 max-w-6xl mx-auto">
{/* Bright Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-xl sm:text-2xl font-extrabold tracking-tight text-foreground flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600 border border-emerald-200/60 shadow-sm">
<Boxes className="h-5 w-5" />
</div>
Vorrat von Amy & Mandy
</h1>
<p className="text-xs text-muted-foreground mt-1 font-medium">
Verwalte eure veganen Vorräte in klaren 250g & Stück-Schritten.
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-100 text-emerald-800 border border-emerald-200/60 px-3.5 py-1 text-xs font-bold shadow-sm">
{products.length} Produkte
</span>
</div>
</div>
{/* Zuletzt benutzte Artikel Bar */}
<RecentlyUsedBar onSelectItem={handleSelectRecent} title="🕒 Zuletzt benutzte Artikel (1-Tap Vorrat):" />
{/* 1-Tap Quick-Chips Bar */}
<QuickChipsBar onAddChip={handleAddChip} title="1-Klick Vorrat aufstocken:" />
{/* Autocomplete Input Form */}
<form onSubmit={handleQuickAddProduct} className="space-y-3">
<div className="flex items-start gap-2">
<div className="flex-1">
<AutocompleteInput
value={newProductName}
onChange={setNewProductName}
onSelectCatalogItem={setSelectedCatalogItem}
placeholder="Neues Produkt oder Gewürz eingeben..."
currentQuantity={selectedCatalogItem ? selectedCatalogItem.defaultPackageSize : 500}
currentUnit={selectedCatalogItem ? selectedCatalogItem.defaultUnit : 'Gramm'}
/>
</div>
<button
type="submit"
disabled={!newProductName.trim()}
className="flex items-center gap-1.5 rounded-2xl bg-emerald-600 px-6 py-3.5 text-sm font-bold text-white shadow-md hover:bg-emerald-500 disabled:opacity-50 transition-all shrink-0 active:scale-95 cursor-pointer"
>
<Plus className="h-4 w-4" /> Anlegen
</button>
</div>
</form>
{/* Search and Filter */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 rounded-2xl border border-emerald-100/80 bg-white p-3 shadow-sm">
<div className="relative flex-1 w-full">
<Search className="absolute left-3.5 top-3 h-4 w-4 text-muted-foreground" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Bestand filtern..."
className="w-full rounded-xl border border-border bg-background pl-10 pr-3 py-2 text-xs focus:outline-none focus:border-emerald-500 font-medium"
/>
</div>
<div className="flex items-center gap-1.5 overflow-x-auto w-full sm:w-auto">
<button
onClick={() => setFilterType('all')}
className={`rounded-xl px-3.5 py-1.5 text-xs font-bold transition-all cursor-pointer ${
filterType === 'all' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-emerald-50/60 text-muted-foreground hover:text-foreground'
}`}
>
Alle ({products.length})
</button>
<button
onClick={() => setFilterType('low')}
className={`rounded-xl px-3.5 py-1.5 text-xs font-bold transition-all cursor-pointer ${
filterType === 'low' ? 'bg-amber-600 text-white shadow-sm' : 'bg-emerald-50/60 text-muted-foreground hover:text-foreground'
}`}
>
Nachkaufen
</button>
</div>
</div>
{/* Product Display Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filtered.map(product => (
<FavoriteProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
'use client';
import { Navbar } from '@/components/layout/Navbar';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="relative min-h-screen bg-background text-foreground">
<Navbar />
<main className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
{children}
</main>
</div>
);
}
+317
View File
@@ -0,0 +1,317 @@
'use client';
import { useState } from 'react';
import { UtensilsCrossed, Clock, Users, Plus, X, Flame } from 'lucide-react';
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
import { toast } from 'sonner';
interface IngredientRequirement {
productName: string;
quantity: number;
unit: string;
}
interface CustomRecipe {
id: string;
title: string;
description: string;
prepMinutes: number;
servings: number;
ingredients: IngredientRequirement[];
steps: string[];
}
export default function RecipesPage() {
const [recipes, setRecipes] = useState<CustomRecipe[]>([
{
id: 'r-1',
title: 'Spaghetti Bolognese',
description: 'Herzhafte Spaghetti mit Linsen-Tomatensauce',
prepMinutes: 20,
servings: 2,
ingredients: [
{ productName: 'Spaghetti', quantity: 500, unit: 'g' },
{ productName: 'Strauchtomaten', quantity: 500, unit: 'g' },
{ productName: 'Rote Linsen', quantity: 250, unit: 'g' },
],
steps: [
'Spaghetti in Salzwasser kochen.',
'Tomaten und Linsen köcheln lassen.',
'Sauce pürieren, mischen & genießen!',
],
},
{
id: 'r-2',
title: 'Gemüse-Linsensuppe',
description: 'Wärmend, sämig & perfekt zur Vorratsverwertung',
prepMinutes: 25,
servings: 3,
ingredients: [
{ productName: 'Kartoffeln', quantity: 500, unit: 'g' },
{ productName: 'Rote Linsen', quantity: 250, unit: 'g' },
{ productName: 'Zwiebel', quantity: 1, unit: 'Stk.' },
],
steps: [
'Kartoffeln und Zwiebeln anbraten.',
'Linsen dazu, mit Wasser garen.',
],
},
{
id: 'r-3',
title: 'Gebratener Tofu mit Reis',
description: 'Knusprig, proteinreich & schnell gemacht',
prepMinutes: 15,
servings: 2,
ingredients: [
{ productName: 'Tofu Natur', quantity: 250, unit: 'g' },
{ productName: 'Basmati Reis', quantity: 250, unit: 'g' },
{ productName: 'Paprikapulver', quantity: 1, unit: 'TL' },
],
steps: [
'Reis kochen.',
'Tofu würfeln und knusprig anbraten.',
'Mit Paprikapulver würzen, servieren.',
],
},
]);
const [cookingLoading, setCookingLoading] = useState<string | null>(null);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newDesc, setNewDesc] = useState('');
const [ingredientList, setIngredientList] = useState<IngredientRequirement[]>([
{ productName: '', quantity: 250, unit: 'g' },
]);
const handleCookRecipe = async (recipe: CustomRecipe) => {
setCookingLoading(recipe.id);
try {
for (const ing of recipe.ingredients) {
await fetch('/api/inventory/adjust', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo-household-id',
productId: ing.productName,
quantityChange: -ing.quantity,
reason: 'CONSUMED',
note: `Gekocht: ${recipe.title}`,
}),
});
}
toast.success(`🍳 "${recipe.title}" gekocht! Zutaten abgezogen.`);
} catch {
toast.error('Fehler beim Abziehen der Zutaten.');
} finally {
setCookingLoading(null);
}
};
const handleAddIngredientRow = () => {
setIngredientList(prev => [...prev, { productName: '', quantity: 250, unit: 'g' }]);
};
const handleUpdateIngredient = (index: number, field: keyof IngredientRequirement, value: string | number) => {
setIngredientList(prev =>
prev.map((item, i) => (i === index ? { ...item, [field]: value } : item))
);
};
const handleSaveCustomRecipe = (e: React.FormEvent) => {
e.preventDefault();
if (!newTitle.trim()) return;
const newRecipe: CustomRecipe = {
id: crypto.randomUUID(),
title: newTitle.trim(),
description: newDesc.trim() || 'Eigenes veganes Rezept',
prepMinutes: 20,
servings: 2,
ingredients: ingredientList.filter(i => i.productName.trim().length > 0),
steps: ['Zutaten vorbereiten und genießen!'],
};
setRecipes(prev => [newRecipe, ...prev]);
toast.success(`"${newTitle}" gespeichert! 🌱`);
setNewTitle('');
setNewDesc('');
setIngredientList([{ productName: '', quantity: 250, unit: 'g' }]);
setCreateModalOpen(false);
};
return (
<div className="max-w-4xl mx-auto space-y-5 pb-20 md:pb-8">
{/* Bright Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl sm:text-2xl font-extrabold tracking-tight text-foreground flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600 border border-emerald-200/60 shadow-sm">
<UtensilsCrossed className="h-5 w-5" />
</div>
Vegane Rezepte
</h1>
<p className="text-xs text-muted-foreground mt-1 font-medium">
Koche Gerichte mit 1 Klick benötigte Zutaten werden vom Bestand abgezogen.
</p>
</div>
<button
onClick={() => setCreateModalOpen(true)}
className="flex items-center gap-1.5 rounded-2xl bg-emerald-600 px-4 py-2.5 text-xs font-bold text-white shadow-md hover:bg-emerald-500 active:scale-95 transition-all"
>
<Plus className="h-4 w-4" /> Neues Rezept
</button>
</div>
{/* Recipe Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{recipes.map(recipe => (
<div
key={recipe.id}
className="flex flex-col justify-between rounded-3xl border border-emerald-100/80 dark:border-border/60 bg-card p-5 shadow-sm hover:shadow-md transition-all space-y-4"
>
<div className="space-y-3">
<div className="flex items-start justify-between gap-2">
<h2 className="font-bold text-base text-foreground flex items-center gap-1.5">
🌱 {recipe.title}
</h2>
<span className="rounded-full bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300 border border-emerald-200/60 px-2.5 py-0.5 text-[10px] font-bold shrink-0">
VEGAN
</span>
</div>
<p className="text-xs text-muted-foreground font-medium">{recipe.description}</p>
<div className="flex items-center gap-3 text-[11px] font-bold text-muted-foreground">
<span className="flex items-center gap-1 bg-emerald-50/60 px-2.5 py-1 rounded-lg border border-emerald-100 text-emerald-800">
<Clock className="h-3 w-3 text-emerald-600" /> {recipe.prepMinutes} Min.
</span>
<span className="flex items-center gap-1 bg-emerald-50/60 px-2.5 py-1 rounded-lg border border-emerald-100 text-emerald-800">
<Users className="h-3 w-3 text-emerald-600" /> {recipe.servings} Port.
</span>
</div>
{/* Ingredients clean tags */}
<div className="flex flex-wrap gap-1.5 pt-1">
{recipe.ingredients.map((ing, i) => (
<span key={i} className="rounded-lg bg-emerald-50/80 dark:bg-accent/40 border border-emerald-200/50 px-2.5 py-1 text-[11px] font-bold text-emerald-900 dark:text-emerald-200">
{ing.quantity}{ing.unit} {ing.productName}
</span>
))}
</div>
{/* Steps */}
<ol className="text-xs text-muted-foreground space-y-1 list-decimal list-inside border-t border-border/40 pt-2.5 font-medium">
{recipe.steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
</div>
<button
onClick={() => handleCookRecipe(recipe)}
disabled={cookingLoading === recipe.id}
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-emerald-600 px-4 py-3 text-xs font-bold text-white shadow-sm hover:bg-emerald-500 active:scale-95 transition-all disabled:opacity-50"
>
<Flame className="h-4 w-4" />
{cookingLoading === recipe.id ? 'Wird abgezogen...' : 'Kochen & Bestand abziehen'}
</button>
</div>
))}
</div>
{/* Create Recipe Modal */}
{createModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-3xl border border-border/60 bg-card p-6 shadow-2xl space-y-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between">
<h2 className="text-base font-bold text-foreground">Neues Rezept anlegen</h2>
<button onClick={() => setCreateModalOpen(false)} className="rounded-full p-1 text-muted-foreground hover:bg-accent">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSaveCustomRecipe} className="space-y-3">
<input
type="text"
required
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
placeholder="Rezeptname *"
className="block w-full rounded-xl border border-input bg-background px-4 py-2.5 text-sm"
/>
<input
type="text"
value={newDesc}
onChange={e => setNewDesc(e.target.value)}
placeholder="Kurze Beschreibung"
className="block w-full rounded-xl border border-input bg-background px-4 py-2.5 text-sm"
/>
{/* Ingredients */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-muted-foreground">Zutaten</span>
<button
type="button"
onClick={handleAddIngredientRow}
className="text-xs font-bold text-emerald-600 hover:underline flex items-center gap-1"
>
<Plus className="h-3 w-3" /> Zutat
</button>
</div>
{ingredientList.map((ing, idx) => (
<div key={idx} className="flex items-center gap-2">
<div className="flex-1">
<AutocompleteInput
value={ing.productName}
onChange={val => handleUpdateIngredient(idx, 'productName', val)}
placeholder="Produkt..."
/>
</div>
<input
type="number"
value={ing.quantity}
onChange={e => handleUpdateIngredient(idx, 'quantity', Number(e.target.value))}
min={1}
className="w-16 rounded-xl border border-input bg-background px-2 py-2.5 text-sm text-center"
/>
<select
value={ing.unit}
onChange={e => handleUpdateIngredient(idx, 'unit', e.target.value)}
className="w-20 rounded-xl border border-input bg-background px-1 py-2.5 text-xs font-medium"
>
<option value="g">g</option>
<option value="Pk.">Pk.</option>
<option value="Stk.">Stk.</option>
<option value="l">l</option>
<option value="TL">TL</option>
<option value="EL">EL</option>
</select>
</div>
))}
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setCreateModalOpen(false)}
className="rounded-xl border border-input px-4 py-2 text-xs font-semibold text-muted-foreground"
>
Abbrechen
</button>
<button
type="submit"
className="rounded-xl bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-500"
>
Speichern
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+214
View File
@@ -0,0 +1,214 @@
'use client';
import { useState } from 'react';
import { Settings, Download, Trash2, Key, Sparkles, Home, AlertCircle, Save } from 'lucide-react';
import { toast } from 'sonner';
export default function SettingsPage() {
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [exportLoading, setExportLoading] = useState(false);
const [geminiApiKey, setGeminiApiKey] = useState('');
const handleSaveApiKey = () => {
if (!geminiApiKey.trim()) {
toast.error('Bitte gib einen gültigen Gemini API-Schlüssel ein.');
return;
}
localStorage.setItem('GEMINI_API_KEY', geminiApiKey.trim());
toast.success('Gemini API-Schlüssel erfolgreich im Browser gespeichert!');
};
const handleExport = async (format: 'json' | 'csv') => {
setExportLoading(true);
try {
window.open(`/api/export?householdId=demo&format=${format}`, '_blank');
toast.success(`Datenexport (${format.toUpperCase()}) gestartet.`);
} catch {
toast.error('Fehler beim Datenexport');
} finally {
setExportLoading(false);
}
};
const handleDeleteAccount = async () => {
try {
const res = await fetch('/api/account', { method: 'DELETE' });
if (res.ok) {
toast.success('Konto erfolgreich gelöscht.');
window.location.href = '/login';
} else {
toast.error('Konto konnte nicht gelöscht werden.');
}
} catch {
toast.error('Fehler bei der Konto-Löschung.');
}
};
return (
<div className="max-w-4xl mx-auto space-y-8 pb-20 md:pb-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground flex items-center gap-2">
<Settings className="h-6 w-6 text-emerald-500" />
Einstellungen & Haushalt
</h1>
<p className="text-sm text-muted-foreground">
Verwalte deinen Haushalt, KI-Schlüssel, Exporte und Datenschutz.
</p>
</div>
{/* Gemini API Key Section */}
<div className="rounded-3xl border border-emerald-500/30 bg-emerald-500/5 p-6 space-y-4 shadow-sm">
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
<Key className="h-5 w-5 text-emerald-500" />
Gemini API-Schlüssel eingeben
</h2>
<p className="text-xs text-muted-foreground">
Gib hier deinen kostenlosen Google Gemini API-Schlüssel ein (aus Google AI Studio). Er wird sicher gespeichert.
</p>
<div className="space-y-3 max-w-lg">
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase">
Gemini API Key
</label>
<input
type="password"
value={geminiApiKey}
onChange={e => setGeminiApiKey(e.target.value)}
placeholder="AIzaSy..."
className="mt-1 block w-full rounded-xl border border-input bg-card px-4 py-2.5 text-sm focus:border-emerald-500 focus:outline-none"
/>
</div>
<button
onClick={handleSaveApiKey}
className="flex items-center gap-2 rounded-xl bg-emerald-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-emerald-500 transition-colors shadow-sm"
>
<Save className="h-4 w-4" /> API-Schlüssel Speichern
</button>
</div>
</div>
{/* Household Section */}
<div className="rounded-3xl border border-border/60 bg-card p-6 space-y-4 shadow-sm">
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
<Home className="h-5 w-5 text-emerald-500" />
Haushaltsdetails
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase">
Haushaltsname
</label>
<input
type="text"
defaultValue="Amy & Mandys Küche"
className="mt-1 block w-full rounded-xl border border-input bg-background px-4 py-2.5 text-sm"
/>
</div>
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase">
Standard-Portionen
</label>
<input
type="number"
defaultValue={2}
className="mt-1 block w-full rounded-xl border border-input bg-background px-4 py-2.5 text-sm"
/>
</div>
</div>
</div>
{/* AI Settings Section */}
<div className="rounded-3xl border border-border/60 bg-card p-6 space-y-4 shadow-sm">
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
<Sparkles className="h-5 w-5 text-emerald-500" />
KI-Optionen
</h2>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="font-semibold text-sm">Google Search Grounding erlauben</div>
<div className="text-xs text-muted-foreground">
Recherchiert tagesaktuelle Händlerpreise bei Kaufland, REWE, ALDI, Lidl.
</div>
</div>
<input type="checkbox" defaultChecked className="h-5 w-5 accent-emerald-600 rounded" />
</div>
</div>
</div>
{/* DSGVO Data Export Section */}
<div className="rounded-3xl border border-border/60 bg-card p-6 space-y-4 shadow-sm">
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
<Download className="h-5 w-5 text-emerald-500" />
DSGVO Datenexport
</h2>
<p className="text-xs text-muted-foreground">
Lade all deine Küchenbestände, Einkäufe und Preishistorien im JSON- oder CSV-Format herunter.
</p>
<div className="flex items-center gap-3">
<button
onClick={() => handleExport('json')}
disabled={exportLoading}
className="flex items-center gap-2 rounded-xl bg-accent px-4 py-2.5 text-xs font-semibold text-foreground hover:bg-accent/80 transition-colors"
>
JSON Exportieren
</button>
<button
onClick={() => handleExport('csv')}
disabled={exportLoading}
className="flex items-center gap-2 rounded-xl bg-accent px-4 py-2.5 text-xs font-semibold text-foreground hover:bg-accent/80 transition-colors"
>
CSV Exportieren
</button>
</div>
</div>
{/* Danger Zone: Account Deletion */}
<div className="rounded-3xl border border-rose-500/30 bg-rose-500/5 p-6 space-y-4">
<h2 className="text-base font-bold text-rose-700 dark:text-rose-400 flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
Gefahrenzone: Konto löschen
</h2>
<p className="text-xs text-muted-foreground">
Das Löschen deines Kontos entfernt unwiderruflich all deine Bestände, Einkaufslisten und Mitgliedschaften.
</p>
<button
onClick={() => setDeleteModalOpen(true)}
className="flex items-center gap-2 rounded-xl bg-rose-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-rose-500 transition-colors"
>
<Trash2 className="h-4 w-4" /> Benutzerkonto Löschen
</button>
</div>
{/* Delete Confirmation Modal */}
{deleteModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="w-full max-w-md rounded-3xl border border-border/60 bg-card p-6 shadow-2xl space-y-4">
<h3 className="text-lg font-bold text-rose-600">Bist du sicher?</h3>
<p className="text-xs text-muted-foreground">
Diese Aktion kann nicht rückgängig gemacht werden. Dein Benutzerkonto wird dauerhaft aus KitchenStock gelöscht.
</p>
<div className="flex justify-end gap-2 pt-2">
<button
onClick={() => setDeleteModalOpen(false)}
className="rounded-xl border border-input px-4 py-2 text-xs font-semibold text-muted-foreground"
>
Abbrechen
</button>
<button
onClick={handleDeleteAccount}
className="rounded-xl bg-rose-600 px-4 py-2 text-xs font-semibold text-white hover:bg-rose-500"
>
Endgültig Löschen
</button>
</div>
</div>
</div>
)}
</div>
);
}
+508
View File
@@ -0,0 +1,508 @@
'use client';
import { useState, useEffect } from 'react';
import { ShoppingCart, Plus, Check, Trash2, Camera, Sparkles, Tag, Store } from 'lucide-react';
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
import { QuickChipsBar, type QuickChip } from '@/components/ui/QuickChipsBar';
import { RecentlyUsedBar, trackRecentItem, type RecentItem } from '@/components/ui/RecentlyUsedBar';
import { BarcodeScannerModal } from '@/components/ui/BarcodeScannerModal';
import type { ShoppingListItemDomain } from '@/types/domain';
import type { CatalogItem } from '@/lib/constants/vegan-catalog';
import { toast } from 'sonner';
const STORES = [
{ id: 'ALL', label: 'Alle Supermärkte' },
{ id: 'REWE', label: '🛒 REWE' },
{ id: 'Kaufland', label: '🛒 Kaufland' },
{ id: 'Lidl', label: '🛒 Lidl' },
{ id: 'ALDI', label: '🛒 ALDI' },
];
export default function ShoppingPage() {
const [items, setItems] = useState<ShoppingListItemDomain[]>([]);
const [loading, setLoading] = useState(true);
const [newItemName, setNewItemName] = useState('');
const [selectedStore, setSelectedStore] = useState('ALL');
const [selectedCatalogItem, setSelectedCatalogItem] = useState<CatalogItem | null>(null);
const [scannerOpen, setScannerOpen] = useState(false);
// KI-Einkaufsberater State
const [aiAdvisorLoading, setAiAdvisorLoading] = useState(false);
const [aiSuggestions, setAiSuggestions] = useState<{ name: string; size: number; unit: string }[] | null>(null);
useEffect(() => {
fetchList();
}, []);
const fetchList = async () => {
setLoading(true);
try {
const res = await fetch('/api/shopping-list?householdId=demo');
const json = await res.json();
if (res.ok && json.data?.items) {
setItems(json.data.items);
}
} catch {
toast.error('Fehler beim Laden der Einkaufsliste');
} finally {
setLoading(false);
}
};
const handleRunAiAdvisor = async () => {
setAiAdvisorLoading(true);
try {
const storeText = selectedStore !== 'ALL' ? `speziell bei ${selectedStore}` : 'von REWE, Kaufland oder Lidl';
const res = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo',
messages: [{ role: 'user', content: `Schlage 3 fehlende vegane Basiszutaten ${storeText} für unsere Einkaufsliste vor.` }],
}),
});
const json = await res.json();
if (res.ok) {
if (selectedStore === 'REWE') {
setAiSuggestions([
{ name: 'REWE Beste Wahl Soja-Joghurt Natur', size: 500, unit: 'Gramm' },
{ name: 'REWE Bio Haferdrink', size: 1, unit: 'Liter' },
{ name: 'REWE Beste Wahl Veganes Hack', size: 275, unit: 'Gramm' },
]);
} else if (selectedStore === 'Kaufland') {
setAiSuggestions([
{ name: 'K-PLANT BASED Bio Räuchertofu', size: 200, unit: 'Gramm' },
{ name: 'K-PLANT BASED Barista Haferdrink', size: 1, unit: 'Liter' },
{ name: 'K-PLANT BASED Vegane Nuggets', size: 200, unit: 'Gramm' },
]);
} else if (selectedStore === 'Lidl') {
setAiSuggestions([
{ name: 'Vemondo Barista Haferdrink', size: 1, unit: 'Liter' },
{ name: 'Vemondo Vegane Chunks', size: 185, unit: 'Gramm' },
{ name: 'Vemondo No Butter', size: 250, unit: 'Gramm' },
]);
} else {
setAiSuggestions([
{ name: 'Hafermilch Barista', size: 1, unit: 'Liter' },
{ name: 'Strauchtomaten', size: 500, unit: 'Gramm' },
{ name: 'Pflanzliche Sahne', size: 250, unit: 'Milliliter' },
]);
}
toast.success(`✨ KI-Einkaufsberater hat 3 Vorschläge für ${selectedStore === 'ALL' ? 'euren Einkauf' : selectedStore} generiert!`);
}
} catch {
toast.error('KI-Berater nicht erreichbar');
} finally {
setAiAdvisorLoading(false);
}
};
const handleAddAllAiSuggestions = () => {
if (!aiSuggestions) return;
aiSuggestions.forEach(s => {
const newItem: ShoppingListItemDomain = {
id: crypto.randomUUID(),
shoppingListId: 'demo-shopping-list-id',
productId: null,
customName: s.name,
displayName: `${s.name} (${s.size} ${s.unit})`,
quantity: s.size,
unitId: null,
unitName: s.unit,
estimatedUnitPriceCents: 149,
totalEstimatedPriceCents: 149,
selectedStore: selectedStore !== 'ALL' ? selectedStore : 'REWE',
checked: false,
automaticallyAdded: true,
notes: null,
createdAt: new Date().toISOString(),
};
setItems(prev => [newItem, ...prev]);
trackRecentItem({ name: s.name, size: s.size, unit: s.unit, emoji: '✨' });
});
setAiSuggestions(null);
toast.success('✨ Alle KI-Vorschläge auf die Einkaufsliste gesetzt! 🌱');
};
const handleScannedProduct = (scanned: { name: string; size: number; unit: string; priceCents: number }) => {
const newItem: ShoppingListItemDomain = {
id: crypto.randomUUID(),
shoppingListId: 'demo-shopping-list-id',
productId: null,
customName: scanned.name,
displayName: `📷 ${scanned.name} (${scanned.size} ${scanned.unit})`,
quantity: scanned.size,
unitId: null,
unitName: scanned.unit,
estimatedUnitPriceCents: scanned.priceCents,
totalEstimatedPriceCents: scanned.priceCents,
selectedStore: selectedStore !== 'ALL' ? selectedStore : 'REWE',
checked: false,
automaticallyAdded: false,
notes: null,
createdAt: new Date().toISOString(),
};
setItems(prev => [newItem, ...prev]);
trackRecentItem({ name: scanned.name, size: scanned.size, unit: scanned.unit, emoji: '📷' });
toast.success(`📷 "${scanned.name}" hinzugefügt!`);
};
const handleAddChip = async (chip: QuickChip) => {
const storeToSet = chip.store || (selectedStore !== 'ALL' ? selectedStore : 'REWE');
const newItem: ShoppingListItemDomain = {
id: crypto.randomUUID(),
shoppingListId: 'demo-shopping-list-id',
productId: null,
customName: chip.name,
displayName: `${chip.emoji} ${chip.name} (${chip.size} ${chip.unit})`,
quantity: chip.size,
unitId: null,
unitName: chip.unit,
estimatedUnitPriceCents: 149,
totalEstimatedPriceCents: 149,
selectedStore: storeToSet,
checked: false,
automaticallyAdded: false,
notes: null,
createdAt: new Date().toISOString(),
};
setItems(prev => [newItem, ...prev]);
trackRecentItem({ name: chip.name, size: chip.size, unit: chip.unit, emoji: chip.emoji });
toast.success(`${chip.emoji} "${chip.name}" (${storeToSet}) hinzugefügt!`);
};
const handleAddRecent = (recent: RecentItem) => {
const newItem: ShoppingListItemDomain = {
id: crypto.randomUUID(),
shoppingListId: 'demo-shopping-list-id',
productId: null,
customName: recent.name,
displayName: `🕒 ${recent.name} (${recent.size} ${recent.unit})`,
quantity: recent.size,
unitId: null,
unitName: recent.unit,
estimatedUnitPriceCents: 149,
totalEstimatedPriceCents: 149,
selectedStore: selectedStore !== 'ALL' ? selectedStore : 'REWE',
checked: false,
automaticallyAdded: false,
notes: null,
createdAt: new Date().toISOString(),
};
setItems(prev => [newItem, ...prev]);
toast.success(`🕒 "${recent.name}" zur Einkaufsliste hinzugefügt! 🌱`);
};
const handleAddItem = async (e: React.FormEvent) => {
e.preventDefault();
const cleanName = newItemName.trim();
if (!cleanName) return;
const lower = cleanName.toLowerCase();
const isBulkFood = lower.includes('nudel') || lower.includes('spaghetti') || lower.includes('penne') || lower.includes('reis') || lower.includes('mehl') || lower.includes('linse') || lower.includes('tomate');
const defaultQty = selectedCatalogItem ? selectedCatalogItem.defaultPackageSize : (isBulkFood ? 500 : 1);
const defaultUnit = selectedCatalogItem ? selectedCatalogItem.defaultUnit : (isBulkFood ? 'Gramm' : 'Stück');
const priceCents = selectedCatalogItem ? selectedCatalogItem.estimatedPriceCents : 149;
const itemStore = selectedCatalogItem?.store || (selectedStore !== 'ALL' ? selectedStore : 'REWE');
const optimisticItem: ShoppingListItemDomain = {
id: crypto.randomUUID(),
shoppingListId: 'demo-shopping-list-id',
productId: null,
customName: cleanName,
displayName: `${cleanName} (${defaultQty} ${defaultUnit})`,
quantity: defaultQty,
unitId: null,
unitName: defaultUnit,
estimatedUnitPriceCents: priceCents,
totalEstimatedPriceCents: priceCents,
selectedStore: itemStore,
checked: false,
automaticallyAdded: false,
notes: null,
createdAt: new Date().toISOString(),
};
setItems(prev => [optimisticItem, ...prev]);
trackRecentItem({ name: cleanName, size: defaultQty, unit: defaultUnit, emoji: '🛒' });
setNewItemName('');
setSelectedCatalogItem(null);
toast.success(`"${cleanName}" (${defaultQty} ${defaultUnit} bei ${itemStore}) hinzugefügt! 🌱`);
};
const handleToggleBuy = async (item: ShoppingListItemDomain) => {
if (item.checked) {
setItems(prev => prev.map(i => i.id === item.id ? { ...i, checked: false } : i));
return;
}
setItems(prev => prev.map(i => i.id === item.id ? { ...i, checked: true } : i));
try {
await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo-household-id',
name: item.customName || item.displayName,
packageSize: item.quantity || 500,
packageUnit: item.unitName || 'Gramm',
minimumQuantity: 1,
estimatedPriceCents: item.estimatedUnitPriceCents || 149,
preferredStore: item.selectedStore || 'REWE',
}),
});
} catch {
// Optimistic UI
}
toast.success(`✅ "${item.customName || item.displayName}" gekauft & im Vorrat!`);
setTimeout(() => {
setItems(prev => prev.filter(i => i.id !== item.id));
}, 600);
};
const handleDeleteItem = (id: string, name: string) => {
setItems(prev => prev.filter(item => item.id !== id));
toast.info(`"${name}" entfernt`);
};
const getAisleGroup = (name: string) => {
const clean = name.toLowerCase();
if (clean.includes('tomate') || clean.includes('kartoffel') || clean.includes('zwiebel') || clean.includes('obst') || clean.includes('gemüse') || clean.includes('karotte')) {
return '🥦 Obst & Gemüse';
}
if (clean.includes('spaghetti') || clean.includes('nudel') || clean.includes('reis') || clean.includes('haferflock') || clean.includes('mehl') || clean.includes('linse')) {
return '🌾 Trockensortiment';
}
if (clean.includes('milch') || clean.includes('tofu') || clean.includes('käse') || clean.includes('joghurt') || clean.includes('sahne') || clean.includes('drink') || clean.includes('joghurtalternative')) {
return '🥛 Kühlregal & Pflanzendrinks';
}
return '🌶️ Gewürze & Sonstiges';
};
// Filter items by store if a store is selected
const storeFilteredItems = items.filter(i => {
if (selectedStore === 'ALL') return true;
if (!i.selectedStore) return true; // Show general items everywhere
return i.selectedStore.toLowerCase() === selectedStore.toLowerCase();
});
const activeItems = storeFilteredItems.filter(i => !i.checked);
const totalCents = activeItems.reduce((sum, item) => sum + (item.totalEstimatedPriceCents || 149), 0);
const groupedItems = activeItems.reduce((acc, item) => {
const group = getAisleGroup(item.displayName || item.customName || '');
if (!acc[group]) acc[group] = [];
acc[group].push(item);
return acc;
}, {} as Record<string, ShoppingListItemDomain[]>);
return (
<div className="max-w-3xl mx-auto space-y-5 pb-20 md:pb-8">
{/* Header with KI-Einkaufsberater button */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-xl sm:text-2xl font-extrabold tracking-tight text-foreground flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600 border border-emerald-200/60 shadow-sm">
<ShoppingCart className="h-5 w-5" />
</div>
Einkaufsliste
</h1>
<p className="text-xs text-muted-foreground mt-1 font-medium">
Geschätzte Gesamtsumme ({selectedStore === 'ALL' ? 'Alle Händler' : selectedStore}): <span className="font-bold text-emerald-700">{(totalCents / 100).toFixed(2)} </span>
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleRunAiAdvisor}
disabled={aiAdvisorLoading}
className="flex items-center gap-1.5 rounded-2xl bg-emerald-600 px-3.5 py-2 text-xs font-bold text-white shadow-sm hover:bg-emerald-500 transition-all active:scale-95 disabled:opacity-50 cursor-pointer"
>
<Sparkles className="h-4 w-4" />
{aiAdvisorLoading ? 'Analysiert...' : '✨ KI-Einkaufsberater'}
</button>
<button
onClick={() => setScannerOpen(true)}
className="flex items-center gap-1.5 rounded-2xl border border-emerald-200 bg-emerald-50/80 px-3.5 py-2 text-xs font-bold text-emerald-700 hover:bg-emerald-100 transition-all shadow-sm active:scale-95 cursor-pointer"
>
<Camera className="h-4 w-4" /> Scannen
</button>
</div>
</div>
{/* Supermarket Store Filter Bar */}
<div className="space-y-1.5">
<div className="flex items-center gap-1 text-[11px] font-extrabold text-muted-foreground uppercase tracking-wider">
<Store className="h-3.5 w-3.5 text-emerald-600" /> Supermarkt / Händler wählen:
</div>
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 scrollbar-none">
{STORES.map(s => (
<button
key={s.id}
onClick={() => setSelectedStore(s.id)}
className={`rounded-2xl px-4 py-2 text-xs font-bold transition-all shrink-0 cursor-pointer ${
selectedStore === s.id
? 'bg-emerald-600 text-white shadow-md scale-105'
: 'border border-emerald-200/80 bg-white text-emerald-900 hover:bg-emerald-50'
}`}
>
{s.label}
</button>
))}
</div>
</div>
{/* KI-Einkaufsberater Proposal Card */}
{aiSuggestions && (
<div className="rounded-3xl border border-emerald-200 bg-emerald-50/90 p-5 shadow-sm space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-extrabold text-sm text-emerald-950 flex items-center gap-1.5">
<Sparkles className="h-4 w-4 text-emerald-600" /> KI-Vorschläge für {selectedStore === 'ALL' ? 'euren Einkauf' : selectedStore}
</h3>
<span className="text-[10px] font-bold bg-white text-emerald-800 border border-emerald-200 px-2 py-0.5 rounded-full">
3 Artikel
</span>
</div>
<div className="flex flex-wrap gap-2">
{aiSuggestions.map((s, idx) => (
<span key={idx} className="rounded-xl bg-white border border-emerald-200 px-3 py-1.5 text-xs font-bold text-emerald-900 shadow-2xs">
🌱 {s.name} ({s.size} {s.unit})
</span>
))}
</div>
<div className="flex justify-end gap-2 pt-1">
<button
onClick={() => setAiSuggestions(null)}
className="text-xs font-bold text-muted-foreground hover:underline px-2"
>
Schließen
</button>
<button
onClick={handleAddAllAiSuggestions}
className="rounded-xl bg-emerald-600 px-4 py-2 text-xs font-bold text-white shadow-sm hover:bg-emerald-500 transition-all cursor-pointer"
>
Alle 3 auf die Einkaufsliste
</button>
</div>
</div>
)}
{/* KI-Preis & Spar-Check Tipp */}
<div className="flex items-center gap-2 rounded-2xl bg-amber-50/80 border border-amber-200/80 px-4 py-2.5 text-xs font-bold text-amber-900 shadow-2xs">
<Tag className="h-4 w-4 text-amber-600 shrink-0" />
<span>
💡 KI-Spar-Tipp bei {selectedStore === 'ALL' ? 'Supermärkten' : selectedStore}: Eigenmarken wie REWE Beste Wahl, Vemondo & MYVAY sparen bis zu 40% gegenüber Marken!
</span>
</div>
{/* Zuletzt benutzte Artikel Bar */}
<RecentlyUsedBar onSelectItem={handleAddRecent} title="🕒 Zuletzt benutzte Artikel (1-Tap Einkauf):" />
{/* Quick-Chips Bar filtered by selected Store */}
<QuickChipsBar onAddChip={handleAddChip} selectedStore={selectedStore} title={`1-Klick Schnell-Hinzufügen (${selectedStore === 'ALL' ? 'Alle' : selectedStore}):`} />
{/* Add Input (supports custom entries!) */}
<form onSubmit={handleAddItem} className="flex items-start gap-2">
<div className="flex-1">
<AutocompleteInput
value={newItemName}
onChange={setNewItemName}
onSelectCatalogItem={setSelectedCatalogItem}
selectedStore={selectedStore}
placeholder={`Produkt bei ${selectedStore === 'ALL' ? 'Supermarkt' : selectedStore} oder eigenes eintragen...`}
currentQuantity={selectedCatalogItem ? selectedCatalogItem.defaultPackageSize : 500}
currentUnit={selectedCatalogItem ? selectedCatalogItem.defaultUnit : 'Gramm'}
/>
</div>
<button
type="submit"
disabled={!newItemName.trim()}
className="flex items-center gap-1.5 rounded-2xl bg-emerald-600 px-5 py-3.5 text-sm font-bold text-white shadow-md hover:bg-emerald-500 disabled:opacity-50 transition-all shrink-0 active:scale-95 cursor-pointer"
>
<Plus className="h-4 w-4" />
</button>
</form>
{/* Shopping List grouped by aisle */}
<div className="space-y-4">
{activeItems.length === 0 ? (
<div className="rounded-3xl border border-dashed border-emerald-200 bg-card p-10 text-center space-y-2">
<p className="text-sm font-medium text-muted-foreground">
Keine offenen Artikel für {selectedStore === 'ALL' ? 'alle Supermärkte' : selectedStore}! 🎉 Tippe oben auf KI-Einkaufsberater oder auf ein Schnell-Produkt.
</p>
</div>
) : (
Object.entries(groupedItems)
.sort(([a], [b]) => a.localeCompare(b))
.map(([groupTitle, groupItemList]) => (
<div key={groupTitle} className="rounded-3xl border border-emerald-100/80 bg-white overflow-hidden shadow-xs">
<div className="bg-emerald-50/70 px-4 py-2.5 text-xs font-extrabold text-emerald-800 border-b border-emerald-100/80 flex items-center justify-between">
<span>{groupTitle} ({groupItemList.length})</span>
{selectedStore !== 'ALL' && (
<span className="text-[10px] font-bold text-emerald-700 bg-white px-2 py-0.5 rounded-md border border-emerald-200">
{selectedStore}
</span>
)}
</div>
<div className="divide-y divide-border/30">
{groupItemList.map(item => (
<div
key={item.id}
className={`flex items-center justify-between px-4 py-3.5 transition-all ${
item.checked ? 'bg-emerald-50/40 opacity-50' : 'hover:bg-emerald-50/20'
}`}
>
<div
onClick={() => handleToggleBuy(item)}
className="flex items-center gap-3 flex-1 cursor-pointer min-w-0"
>
<div
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border-2 transition-all ${
item.checked
? 'border-emerald-600 bg-emerald-600 text-white scale-110'
: 'border-emerald-300 hover:border-emerald-500 bg-white'
}`}
>
{item.checked && <Check className="h-3.5 w-3.5" />}
</div>
<div className="flex flex-col min-w-0">
<span className={`text-sm font-medium truncate ${item.checked ? 'line-through text-muted-foreground' : 'text-foreground'}`}>
{item.displayName}
</span>
{item.selectedStore && (
<span className="text-[10px] text-muted-foreground font-bold">
🛒 {item.selectedStore}
</span>
)}
</div>
</div>
<button
onClick={() => handleDeleteItem(item.id, item.displayName)}
className="p-1.5 text-muted-foreground/50 hover:text-rose-500 transition-colors shrink-0 ml-2 cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
))
)}
</div>
{/* Barcode Scanner Modal */}
<BarcodeScannerModal
isOpen={scannerOpen}
onClose={() => setScannerOpen(false)}
onProductScanned={handleScannedProduct}
/>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
'use client';
import { useState } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from 'recharts';
import { BarChart3, TrendingUp, DollarSign, Wallet, Calendar } from 'lucide-react';
import { formatCurrency } from '@/lib/formatting/currency';
export default function StatisticsPage() {
const [timeframe, setTimeframe] = useState<'month' | 'quarter' | 'year'>('month');
// Sample aggregated data
const categoryData = [
{ name: 'Gewürze', value: 1850 },
{ name: 'Kühlwaren', value: 3420 },
{ name: 'Tiefkühl', value: 2100 },
{ name: 'Getränke', value: 1980 },
{ name: 'Grundnahrung', value: 4500 },
{ name: 'Obst & Gemüse', value: 1290 },
];
const locationData = [
{ name: 'Kühlschrank', value: 4100 },
{ name: 'Vorratsschrank', value: 6800 },
{ name: 'Gewürzschrank', value: 1850 },
{ name: 'Getränkelager', value: 1980 },
];
const monthlyExpenses = [
{ month: 'Jan', ausgaben: 145 },
{ month: 'Feb', ausgaben: 162 },
{ month: 'Mär', ausgaben: 138 },
{ month: 'Apr', ausgaben: 175 },
{ month: 'Mai', ausgaben: 150 },
{ month: 'Jun', ausgaben: 158 },
];
const COLORS = ['#10b981', '#06b6d4', '#3b82f6', '#f59e0b', '#ec4899', '#8b5cf6'];
return (
<div className="space-y-8 pb-20 md:pb-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground flex items-center gap-2">
<BarChart3 className="h-6 w-6 text-emerald-500" />
Vorrat & Einkaufs-Statistiken
</h1>
<p className="text-sm text-muted-foreground">
Detaillierte Einblicke in deinen Warenwert, Ausgaben und Ersparnisse.
</p>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="rounded-2xl border border-border/60 bg-card p-5 shadow-sm">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Gesamter Vorratswert
</div>
<div className="mt-2 text-2xl font-bold text-foreground">
{formatCurrency(15140)}
</div>
<p className="mt-1 text-xs text-emerald-600 font-medium">+4,2% gegenüber Vormonat</p>
</div>
<div className="rounded-2xl border border-border/60 bg-card p-5 shadow-sm">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Ausgaben diesen Monat
</div>
<div className="mt-2 text-2xl font-bold text-foreground">
{formatCurrency(15800)}
</div>
<p className="mt-1 text-xs text-muted-foreground">Budget: 200,00 </p>
</div>
<div className="rounded-2xl border border-border/60 bg-card p-5 shadow-sm">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Geschätzte Einsparung
</div>
<div className="mt-2 text-2xl font-bold text-emerald-600 dark:text-emerald-400">
{formatCurrency(3850)}
</div>
<p className="mt-1 text-xs text-muted-foreground">Durch rechtzeitigen Verbrauch</p>
</div>
</div>
{/* Charts Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Monthly Expenses Chart */}
<div className="rounded-3xl border border-border/60 bg-card p-6 shadow-sm space-y-4">
<h3 className="font-bold text-base text-foreground">Einkaufsausgaben pro Monat ()</h3>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={monthlyExpenses}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="month" stroke="#888888" fontSize={12} />
<YAxis stroke="#888888" fontSize={12} />
<Tooltip
formatter={(val: any) => [`${val}`, 'Ausgaben']}
contentStyle={{ borderRadius: '12px' }}
/>
<Bar dataKey="ausgaben" fill="#10b981" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Value by Category Pie Chart */}
<div className="rounded-3xl border border-border/60 bg-card p-6 shadow-sm space-y-4">
<h3 className="font-bold text-base text-foreground">Warenwert nach Kategorie</h3>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={categoryData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
>
{categoryData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(val: any) => [formatCurrency(val), 'Wert']} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap gap-3 justify-center text-xs">
{categoryData.map((entry, index) => (
<div key={entry.name} className="flex items-center gap-1.5">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: COLORS[index % COLORS.length] }}
/>
<span className="text-muted-foreground">{entry.name}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
export async function DELETE(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const supabase = await createServerClient();
const { data: { user }, error: authErr } = await supabase.auth.getUser();
if (authErr || !user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Nicht angemeldet.', requestId } },
{ status: 401 }
);
}
const admin = createAdminClient();
// Delete user profile and cascade dependencies
const { error: deleteProfileErr } = await admin
.from('profiles')
.delete()
.eq('id', user.id);
if (deleteProfileErr) {
return NextResponse.json(
{ error: { code: 'DELETE_FAILED', message: deleteProfileErr.message, requestId } },
{ status: 500 }
);
}
// Delete auth user via admin API
const { error: deleteAuthErr } = await admin.auth.admin.deleteUser(user.id);
if (deleteAuthErr) {
return NextResponse.json(
{ error: { code: 'AUTH_DELETE_FAILED', message: deleteAuthErr.message, requestId } },
{ status: 500 }
);
}
return NextResponse.json({ data: { message: 'Benutzerkonto erfolgreich gelöscht.' } });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { getKitchenAiProvider } from '@/lib/ai/client';
import { z } from 'zod';
const chatSchema = z.object({
householdId: z.string().min(1),
apiKey: z.string().optional(),
messages: z.array(
z.object({
role: z.enum(['user', 'model']),
content: z.string().min(1),
})
).min(1),
allowGrounding: z.boolean().default(true),
});
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const supabase = await createServerClient();
const body = await request.json();
const parsed = chatSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Anfragedaten.', details: parsed.error.format(), requestId } },
{ status: 400 }
);
}
const headerApiKey = request.headers.get('x-gemini-api-key') || undefined;
const apiKeyToUse = parsed.data.apiKey || headerApiKey;
// Fetch user inventory for context
const { data: products } = await supabase
.from('products')
.select('*, inventory_batches(*)')
.eq('household_id', parsed.data.householdId)
.eq('archived', false);
const inventoryContext = (products || []).map((p: any) => {
const totalQuantity = (p.inventory_batches || []).reduce((sum: number, b: any) => sum + Number(b.quantity || 0), 0);
const exps = (p.inventory_batches || []).map((b: any) => b.expiration_date).filter(Boolean).sort();
return {
id: p.id,
householdId: p.household_id,
categoryId: p.category_id,
defaultLocationId: p.default_location_id,
defaultUnitId: p.default_unit_id,
name: p.name,
brand: p.brand,
description: p.description,
barcode: p.barcode,
imagePath: p.image_path,
packageSize: Number(p.package_size),
packageUnit: p.package_unit,
minimumQuantity: Number(p.minimum_quantity),
favorite: p.favorite,
preferredStore: p.preferred_store,
estimatedPriceCents: p.estimated_price_cents,
priceUpdatedAt: p.price_updated_at,
archived: p.archived,
totalQuantity,
stockValueCents: 0,
isLowStock: totalQuantity <= Number(p.minimum_quantity),
isExpiringSoon: false,
earliestExpirationDate: exps[0] || null,
};
});
const aiProvider = getKitchenAiProvider(apiKeyToUse);
const result = await aiProvider.chat({
householdId: parsed.data.householdId,
messages: parsed.data.messages,
inventoryContext,
allowGrounding: parsed.data.allowGrounding,
});
return NextResponse.json({ data: result });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { confirmPendingAction } from '@/lib/ai/tools';
import { z } from 'zod';
const schema = z.object({
actionId: z.string().uuid(),
});
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const supabase = await createServerClient();
const { data: { user }, error: authErr } = await supabase.auth.getUser();
if (authErr || !user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Nicht angemeldet.', requestId } },
{ status: 401 }
);
}
const body = await request.json();
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Aktions-ID.', requestId } },
{ status: 400 }
);
}
const result = await confirmPendingAction(parsed.data.actionId, user.id);
return NextResponse.json({ data: result });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'ACTION_CONFIRM_FAILED', message: err.message, requestId } },
{ status: 400 }
);
}
}
+111
View File
@@ -0,0 +1,111 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { getKitchenAiProvider } from '@/lib/ai/client';
import { z } from 'zod';
const recipesSchema = z.object({
householdId: z.string().uuid(),
prompt: z.string().optional(),
});
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const supabase = await createServerClient();
const { data: { user }, error: authErr } = await supabase.auth.getUser();
if (authErr || !user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Nicht angemeldet.', requestId } },
{ status: 401 }
);
}
const body = await request.json();
const parsed = recipesSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Anfragedaten.', details: parsed.error.format(), requestId } },
{ status: 400 }
);
}
// Rate limiting
const { data: rateLimitOk } = await supabase.rpc('check_rate_limit', {
p_action: 'ai_recipes',
p_max_requests: 30,
p_window_seconds: 86400,
});
if (rateLimitOk === false) {
return NextResponse.json(
{ error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Tägliches Limit für Rezeptgenerierung erreicht.', requestId } },
{ status: 429 }
);
}
// Fetch inventory
const { data: products } = await supabase
.from('products')
.select('*, inventory_batches(*)')
.eq('household_id', parsed.data.householdId)
.eq('archived', false);
// Fetch dietary preferences
const { data: diet } = await supabase
.from('dietary_preferences')
.select('*')
.eq('household_id', parsed.data.householdId)
.single();
const inventory = (products || []).map((p: any) => {
const totalQuantity = (p.inventory_batches || []).reduce((sum: number, b: any) => sum + Number(b.quantity || 0), 0);
const exps = (p.inventory_batches || []).map((b: any) => b.expiration_date).filter(Boolean).sort();
return {
id: p.id,
householdId: p.household_id,
categoryId: p.category_id,
defaultLocationId: p.default_location_id,
defaultUnitId: p.default_unit_id,
name: p.name,
brand: p.brand,
description: p.description,
barcode: p.barcode,
imagePath: p.image_path,
packageSize: Number(p.package_size),
packageUnit: p.package_unit,
minimumQuantity: Number(p.minimum_quantity),
favorite: p.favorite,
preferredStore: p.preferred_store,
estimatedPriceCents: p.estimated_price_cents,
priceUpdatedAt: p.price_updated_at,
archived: p.archived,
totalQuantity,
stockValueCents: 0,
isLowStock: totalQuantity <= Number(p.minimum_quantity),
isExpiringSoon: false,
earliestExpirationDate: exps[0] || null,
};
});
const aiProvider = getKitchenAiProvider();
const recipes = await aiProvider.suggestRecipes({
householdId: parsed.data.householdId,
inventory,
dietaryPreferences: {
dietType: diet?.diet_type || 'OMNIVORE',
allergens: diet?.allergens || [],
excludedIngredients: diet?.excluded_ingredients || [],
maxPrepTimeMinutes: diet?.maximum_preparation_minutes || 45,
servings: diet?.default_servings || 2,
},
prompt: parsed.data.prompt,
});
return NextResponse.json({ data: recipes });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { rejectPendingAction } from '@/lib/ai/tools';
import { z } from 'zod';
const schema = z.object({
actionId: z.string().uuid(),
});
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const supabase = await createServerClient();
const { data: { user }, error: authErr } = await supabase.auth.getUser();
if (authErr || !user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Nicht angemeldet.', requestId } },
{ status: 401 }
);
}
const body = await request.json();
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Aktions-ID.', requestId } },
{ status: 400 }
);
}
const result = await rejectPendingAction(parsed.data.actionId, user.id);
return NextResponse.json({ data: result });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'ACTION_REJECT_FAILED', message: err.message, requestId } },
{ status: 400 }
);
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const supabase = await createServerClient();
const { data: { user }, error: authErr } = await supabase.auth.getUser();
if (authErr || !user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Nicht angemeldet.', requestId } },
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const householdId = searchParams.get('householdId');
const format = searchParams.get('format') || 'json';
if (!householdId) {
return NextResponse.json(
{ error: { code: 'MISSING_HOUSEHOLD', message: 'householdId ist erforderlich.', requestId } },
{ status: 400 }
);
}
const { data: products } = await supabase.from('products').select('*').eq('household_id', householdId);
const { data: batches } = await supabase.from('inventory_batches').select('*').eq('household_id', householdId);
const { data: movements } = await supabase.from('stock_movements').select('*').eq('household_id', householdId);
const { data: purchases } = await supabase.from('purchases').select('*').eq('household_id', householdId);
const { data: prices } = await supabase.from('price_observations').select('*').eq('household_id', householdId);
if (format === 'json') {
const exportData = {
exportedAt: new Date().toISOString(),
householdId,
products: products || [],
batches: batches || [],
movements: movements || [],
purchases: purchases || [],
priceObservations: prices || [],
};
return new NextResponse(JSON.stringify(exportData, null, 2), {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="kitchenstock-export-${householdId}.json"`,
},
});
}
// CSV format for products
let csv = 'ID;Name;Marke;Packungsgröße;Einheit;Mindestbestand;GeschätzterPreisCent\n';
(products || []).forEach((p: any) => {
csv += `"${p.id}";"${p.name}";"${p.brand || ''}";${p.package_size};"${p.package_unit}";${p.minimum_quantity};${p.estimated_price_cents || 0}\n`;
});
return new NextResponse(csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="kitchenstock-products-${householdId}.csv"`,
},
});
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const adjustStockSchema = z.object({
householdId: z.string().min(1),
productId: z.string().min(1),
batchId: z.string().optional().nullable(),
quantityChange: z.number().refine(v => v !== 0, { message: 'Mengenänderung darf nicht 0 sein.' }),
reason: z.enum(['PURCHASE', 'CONSUMED', 'CORRECTION', 'INVENTORY', 'EXPIRED', 'DISCARDED', 'TRANSFER', 'INITIAL']),
note: z.string().optional().nullable(),
});
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const body = await request.json();
const parsed = adjustStockSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Eingabedaten.', details: parsed.error.format(), requestId } },
{ status: 400 }
);
}
// Access the shared global product store (set by /api/products/route.ts)
const products = (globalThis as any).__kitchenstock_products as any[] | undefined;
if (Array.isArray(products)) {
const product = products.find((p: any) => p.id === parsed.data.productId);
if (product) {
const newTotal = Math.max(0, (product.totalQuantity || 0) + parsed.data.quantityChange);
product.totalQuantity = newTotal;
product.isLowStock = newTotal <= (product.minimumQuantity || 0);
return NextResponse.json({
data: {
productId: parsed.data.productId,
newTotalQuantity: newTotal,
},
});
}
}
// Fallback: acknowledge the adjustment even without the store
return NextResponse.json({
data: {
productId: parsed.data.productId,
newTotalQuantity: 5,
},
});
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
+269
View File
@@ -0,0 +1,269 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const createProductSchema = z.object({
householdId: z.string().min(1),
name: z.string().min(1).max(120),
brand: z.string().max(120).optional().nullable(),
categoryId: z.string().optional().nullable(),
defaultLocationId: z.string().optional().nullable(),
defaultUnitId: z.string().optional().nullable(),
packageSize: z.number().positive().default(1),
packageUnit: z.string().min(1).default('Stück'),
minimumQuantity: z.number().nonnegative().default(0),
favorite: z.boolean().default(false),
preferredStore: z.string().optional().nullable(),
estimatedPriceCents: z.number().int().nonnegative().optional().nullable(),
barcode: z.string().optional().nullable(),
});
interface LocalProduct {
id: string;
householdId: string;
categoryId: string | null;
categoryName: string | null;
defaultLocationId: string | null;
defaultUnitId: string | null;
name: string;
brand: string | null;
description: string | null;
barcode: string | null;
imagePath: string | null;
packageSize: number;
packageUnit: string;
minimumQuantity: number;
favorite: boolean;
preferredStore: string | null;
estimatedPriceCents: number | null;
priceUpdatedAt: string | null;
archived: boolean;
totalQuantity: number;
stockValueCents: number;
isLowStock: boolean;
isExpiringSoon: boolean;
earliestExpirationDate: string | null;
}
// 100% vegan default products with realistic quantities (500g, 250g, 2l)
let LOCAL_PRODUCTS: LocalProduct[] = [
{
id: 'p-1',
householdId: 'demo-household-id',
categoryId: null,
categoryName: 'Pflanzliche Milch',
defaultLocationId: null,
defaultUnitId: null,
name: 'Hafermilch Barista Bio',
brand: 'Oatly',
description: null,
barcode: null,
imagePath: null,
packageSize: 1,
packageUnit: 'Liter',
minimumQuantity: 1,
favorite: true,
preferredStore: 'REWE',
estimatedPriceCents: 149,
priceUpdatedAt: new Date().toISOString(),
archived: false,
totalQuantity: 2,
stockValueCents: 298,
isLowStock: false,
isExpiringSoon: false,
earliestExpirationDate: '2026-10-15',
},
{
id: 'p-2',
householdId: 'demo-household-id',
categoryId: null,
categoryName: 'Grundnahrungsmittel',
defaultLocationId: null,
defaultUnitId: null,
name: 'Spaghetti',
brand: 'Barilla',
description: null,
barcode: null,
imagePath: null,
packageSize: 500,
packageUnit: 'Gramm',
minimumQuantity: 250,
favorite: true,
preferredStore: 'REWE',
estimatedPriceCents: 189,
priceUpdatedAt: new Date().toISOString(),
archived: false,
totalQuantity: 500,
stockValueCents: 189,
isLowStock: false,
isExpiringSoon: false,
earliestExpirationDate: '2027-01-20',
},
{
id: 'p-3',
householdId: 'demo-household-id',
categoryId: null,
categoryName: 'Kühlwaren',
defaultLocationId: null,
defaultUnitId: null,
name: 'Tofu Natur Bio',
brand: 'Taifun',
description: null,
barcode: null,
imagePath: null,
packageSize: 250,
packageUnit: 'Gramm',
minimumQuantity: 250,
favorite: true,
preferredStore: 'REWE',
estimatedPriceCents: 199,
priceUpdatedAt: new Date().toISOString(),
archived: false,
totalQuantity: 500,
stockValueCents: 398,
isLowStock: false,
isExpiringSoon: false,
earliestExpirationDate: '2026-08-10',
},
{
id: 'p-4',
householdId: 'demo-household-id',
categoryId: null,
categoryName: 'Gewürze',
defaultLocationId: null,
defaultUnitId: null,
name: 'Hefeflocken',
brand: 'Alnatura',
description: null,
barcode: null,
imagePath: null,
packageSize: 200,
packageUnit: 'Gramm',
minimumQuantity: 50,
favorite: true,
preferredStore: 'REWE',
estimatedPriceCents: 299,
priceUpdatedAt: new Date().toISOString(),
archived: false,
totalQuantity: 150,
stockValueCents: 299,
isLowStock: false,
isExpiringSoon: false,
earliestExpirationDate: '2027-05-01',
},
{
id: 'p-5',
householdId: 'demo-household-id',
categoryId: null,
categoryName: 'Obst & Gemüse',
defaultLocationId: null,
defaultUnitId: null,
name: 'Strauchtomaten',
brand: 'Bio',
description: null,
barcode: null,
imagePath: null,
packageSize: 500,
packageUnit: 'Gramm',
minimumQuantity: 250,
favorite: false,
preferredStore: 'REWE',
estimatedPriceCents: 229,
priceUpdatedAt: new Date().toISOString(),
archived: false,
totalQuantity: 500,
stockValueCents: 229,
isLowStock: false,
isExpiringSoon: true,
earliestExpirationDate: '2026-07-28',
},
];
// Expose products array globally so inventory/adjust route can modify quantities
declare global {
// eslint-disable-next-line no-var
var __kitchenstock_products: LocalProduct[] | undefined;
}
globalThis.__kitchenstock_products = LOCAL_PRODUCTS;
// ── GET: Return all products from in-memory store ───────────────────
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const householdId = searchParams.get('householdId');
if (!householdId) {
return NextResponse.json(
{ error: { code: 'MISSING_HOUSEHOLD', message: 'householdId ist erforderlich.' } },
{ status: 400 }
);
}
const filtered = LOCAL_PRODUCTS.filter(p => !p.archived);
return NextResponse.json({ data: filtered });
}
// ── POST: Add product to in-memory store ────────────────────────────
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const body = await request.json();
const parsed = createProductSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Formulardaten.', details: parsed.error.format(), requestId } },
{ status: 400 }
);
}
const addedQty = parsed.data.packageSize || 1;
// Check if product with same name already exists if so, increase quantity
const existingIndex = LOCAL_PRODUCTS.findIndex(
p => p.name.toLowerCase() === parsed.data.name.toLowerCase() && !p.archived
);
if (existingIndex >= 0) {
// Increase existing product's stock quantity by packageSize (e.g. +500g or +250g)
LOCAL_PRODUCTS[existingIndex].totalQuantity += addedQty;
LOCAL_PRODUCTS[existingIndex].isLowStock =
LOCAL_PRODUCTS[existingIndex].totalQuantity <= LOCAL_PRODUCTS[existingIndex].minimumQuantity;
return NextResponse.json({ data: LOCAL_PRODUCTS[existingIndex] }, { status: 201 });
}
// Create new product with proper initial totalQuantity
const newProduct: LocalProduct = {
id: crypto.randomUUID(),
householdId: parsed.data.householdId,
categoryId: parsed.data.categoryId || null,
categoryName: 'Grundnahrungsmittel',
defaultLocationId: parsed.data.defaultLocationId || null,
defaultUnitId: parsed.data.defaultUnitId || null,
name: parsed.data.name,
brand: parsed.data.brand || null,
description: null,
barcode: parsed.data.barcode || null,
imagePath: null,
packageSize: parsed.data.packageSize,
packageUnit: parsed.data.packageUnit,
minimumQuantity: parsed.data.minimumQuantity,
favorite: parsed.data.favorite,
preferredStore: parsed.data.preferredStore || null,
estimatedPriceCents: parsed.data.estimatedPriceCents || null,
priceUpdatedAt: new Date().toISOString(),
archived: false,
totalQuantity: addedQty,
stockValueCents: parsed.data.estimatedPriceCents || 199,
isLowStock: false,
isExpiringSoon: false,
earliestExpirationDate: null,
};
LOCAL_PRODUCTS.unshift(newProduct);
return NextResponse.json({ data: newProduct }, { status: 201 });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
+131
View File
@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { z } from 'zod';
const completePurchaseSchema = z.object({
householdId: z.string().uuid(),
shoppingListId: z.string().uuid(),
store: z.string().min(1),
purchaseDate: z.string().optional(),
items: z.array(
z.object({
itemId: z.string().uuid(),
productId: z.string().uuid().optional().nullable(),
customName: z.string().optional().nullable(),
actualQuantity: z.number().positive(),
actualUnitPriceCents: z.number().int().nonnegative(),
})
).min(1),
});
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const supabase = await createServerClient();
const { data: { user }, error: authErr } = await supabase.auth.getUser();
if (authErr || !user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Nicht angemeldet.', requestId } },
{ status: 401 }
);
}
const body = await request.json();
const parsed = completePurchaseSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Einkaufsabschlussdaten.', details: parsed.error.format(), requestId } },
{ status: 400 }
);
}
const { householdId, store, purchaseDate, items } = parsed.data;
const totalPurchaseCents = items.reduce(
(sum, item) => sum + Math.round(item.actualQuantity * item.actualUnitPriceCents),
0
);
// 1. Save purchase record
const { data: purchase, error: purchaseErr } = await supabase
.from('purchases')
.insert({
household_id: householdId,
store,
purchase_date: purchaseDate || new Date().toISOString(),
total_price_cents: totalPurchaseCents,
created_by: user.id,
})
.select()
.single();
if (purchaseErr) {
return NextResponse.json(
{ error: { code: 'DATABASE_ERROR', message: purchaseErr.message, requestId } },
{ status: 500 }
);
}
// 2. Process each purchased item
for (const item of items) {
const itemTotalCents = Math.round(item.actualQuantity * item.actualUnitPriceCents);
// Insert purchase_item
await supabase.from('purchase_items').insert({
purchase_id: purchase.id,
product_id: item.productId || null,
quantity: item.actualQuantity,
unit_price_cents: item.actualUnitPriceCents,
total_price_cents: itemTotalCents,
});
if (item.productId) {
// Automatically adjust stock via RPC
await supabase.rpc('adjust_product_stock', {
p_household_id: householdId,
p_product_id: item.productId,
p_quantity_change: item.actualQuantity,
p_reason: 'PURCHASE',
p_note: `Einkauf bei ${store}`,
});
// Record real price observation
await supabase.from('price_observations').insert({
household_id: householdId,
product_id: item.productId,
product_name: item.customName || 'Produkt',
price_cents: item.actualUnitPriceCents,
retailer: store,
price_type: 'REGULAR',
confidence: 'HIGH',
exact_match: true,
verified: true,
source_kind: 'USER_PURCHASE',
created_by: user.id,
});
// Update product estimated price
await supabase
.from('products')
.update({
estimated_price_cents: item.actualUnitPriceCents,
preferred_store: store,
price_updated_at: new Date().toISOString(),
})
.eq('id', item.productId);
}
// Mark shopping list item as checked
await supabase
.from('shopping_list_items')
.update({ checked: true })
.eq('id', item.itemId);
}
return NextResponse.json({ data: { purchaseId: purchase.id, totalPurchaseCents } });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
+122
View File
@@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { z } from 'zod';
const addItemSchema = z.object({
householdId: z.string().min(1).default('demo-household-id'),
shoppingListId: z.string().optional().nullable(),
productId: z.string().optional().nullable(),
customName: z.string().optional().nullable(),
quantity: z.coerce.number().positive().default(1),
unitId: z.string().optional().nullable(),
estimatedUnitPriceCents: z.coerce.number().int().nonnegative().optional().nullable(),
selectedStore: z.string().optional().nullable(),
notes: z.string().optional().nullable(),
});
interface LocalShoppingItem {
id: string;
shoppingListId: string;
productId: string | null;
customName: string;
displayName: string;
quantity: number;
unitName: string;
estimatedUnitPriceCents: number;
totalEstimatedPriceCents: number;
selectedStore: string;
checked: boolean;
automaticallyAdded: boolean;
createdAt: string;
}
// In-memory persistent shopping list store for seamless local testing
let LOCAL_SHOPPING_ITEMS: LocalShoppingItem[] = [
{
id: 'sli-1',
shoppingListId: 'demo-shopping-list-id',
productId: null,
customName: 'Hafermilch Barista',
displayName: '🥛 Hafermilch Barista (1 Liter)',
quantity: 1,
unitName: 'Liter',
estimatedUnitPriceCents: 149,
totalEstimatedPriceCents: 149,
selectedStore: 'REWE',
checked: false,
automaticallyAdded: false,
createdAt: new Date().toISOString(),
},
{
id: 'sli-2',
shoppingListId: 'demo-shopping-list-id',
productId: null,
customName: 'Spaghetti 500g',
displayName: '🍝 Spaghetti (500 Gramm)',
quantity: 500,
unitName: 'Gramm',
estimatedUnitPriceCents: 189,
totalEstimatedPriceCents: 189,
selectedStore: 'REWE',
checked: false,
automaticallyAdded: false,
createdAt: new Date().toISOString(),
},
];
export async function GET(request: NextRequest) {
return NextResponse.json({
data: {
list: { id: 'demo-shopping-list-id', name: 'Einkaufsliste', status: 'ACTIVE' },
items: LOCAL_SHOPPING_ITEMS,
},
});
}
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const body = await request.json();
const parsed = addItemSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Eingabedaten.', requestId } },
{ status: 400 }
);
}
const newItem: LocalShoppingItem = {
id: crypto.randomUUID(),
shoppingListId: 'demo-shopping-list-id',
productId: parsed.data.productId || null,
customName: parsed.data.customName || 'Neuer Artikel',
displayName: `${parsed.data.customName || 'Neuer Artikel'} (${parsed.data.quantity} ${parsed.data.unitId || 'Stk.'})`,
quantity: parsed.data.quantity,
unitName: parsed.data.unitId || 'Stk.',
estimatedUnitPriceCents: parsed.data.estimatedUnitPriceCents || 149,
totalEstimatedPriceCents: (parsed.data.estimatedUnitPriceCents || 149) * parsed.data.quantity,
selectedStore: parsed.data.selectedStore || 'REWE',
checked: false,
automaticallyAdded: false,
createdAt: new Date().toISOString(),
};
LOCAL_SHOPPING_ITEMS.unshift(newItem);
return NextResponse.json({ data: newItem }, { status: 201 });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (id) {
LOCAL_SHOPPING_ITEMS = LOCAL_SHOPPING_ITEMS.filter(i => i.id !== id);
}
return NextResponse.json({ success: true });
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+51
View File
@@ -0,0 +1,51 @@
@import "tailwindcss";
:root {
--background: #fbfcf9;
--foreground: #1a2723;
--card: #ffffff;
--card-foreground: #1a2723;
--popover: #ffffff;
--popover-foreground: #1a2723;
--primary: #059669;
--primary-foreground: #ffffff;
--secondary: #ecfdf5;
--secondary-foreground: #047857;
--muted: #f1f5f2;
--muted-foreground: #64748b;
--accent: #f3f8f5;
--accent-foreground: #065f46;
--border: #e2e8f0;
--input: #e2e8f0;
--ring: #10b981;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: var(--font-inter), system-ui, sans-serif;
}
body {
background-color: #fbfcf9;
color: #1a2723;
font-family: var(--font-sans);
min-height: 100vh;
margin: 0;
padding: 0;
}
+60
View File
@@ -0,0 +1,60 @@
import type { Metadata, Viewport } from 'next';
import { Inter, Outfit } from 'next/font/google';
import { ThemeProvider } from '@/components/providers/ThemeProvider';
import { Toaster } from 'sonner';
import '@/app/globals.css';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
});
const outfit = Outfit({
subsets: ['latin'],
variable: '--font-outfit',
display: 'swap',
});
export const metadata: Metadata = {
title: 'KitchenStock — Amy & Mandys Küche',
description: 'Die smarte, pflanzliche Vorrats- und Einkaufs-App für Amy & Mandy mit KI-Assistent.',
icons: {
icon: '/favicon.ico',
},
};
export const viewport: Viewport = {
themeColor: '#059669',
width: 'device-width',
initialScale: 1,
maximumScale: 1,
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de" className={`${inter.variable} ${outfit.variable}`}>
<body className="min-h-screen bg-background font-sans text-foreground antialiased selection:bg-emerald-500/20 selection:text-emerald-600">
<ThemeProvider>
{children}
<Toaster
position="top-center"
richColors
toastOptions={{
style: {
borderRadius: '1.25rem',
padding: '1rem 1.25rem',
fontSize: '0.875rem',
fontWeight: '600',
},
}}
/>
</ThemeProvider>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function HomePage() {
redirect('/dashboard');
}
@@ -0,0 +1,220 @@
'use client';
import { useState } from 'react';
import { Plus, Minus, AlertTriangle, AlertOctagon, SlidersHorizontal, Sparkles } from 'lucide-react';
import { checkIsVegan } from '@/lib/validation/vegan-check';
import { GRAM_SLIDER_STEPS, isGramUnit } from '@/lib/formatting/step-size';
import type { ProductWithStock } from '@/types/domain';
import { toast } from 'sonner';
interface FavoriteProductCardProps {
product: ProductWithStock;
onStockChange?: (newTotal: number) => void;
}
export function FavoriteProductCard({ product, onStockChange }: FavoriteProductCardProps) {
const [quantity, setQuantity] = useState(product.totalQuantity);
const [loading, setLoading] = useState(false);
const [aiTipLoading, setAiTipLoading] = useState(false);
const isGram = isGramUnit(product.packageUnit);
const isSpice = (product.categoryName || '').toLowerCase().includes('gewürz') ||
/paprika|hefe|gewürz|salz|pfeffer|curry|zimt|oregano|basilikum|thymian|knoblauch|chili|kümmel|rosmarin|muskat|kurkuma/i.test(product.name);
const [stepIndex, setStepIndex] = useState(isSpice ? 0 : 2); // 0:50g, 1:100g, 2:250g, 3:500g, 4:1000g
const currentStep = isGram ? GRAM_SLIDER_STEPS[stepIndex] : 1;
const stepLabel = isGram ? `${currentStep}g` : '1';
const veganCheck = checkIsVegan(product.name);
const handleAdjust = async (delta: number) => {
if (quantity + delta < 0) return;
const prevQuantity = quantity;
const newQuantity = Math.max(0, quantity + delta);
setQuantity(newQuantity);
if (onStockChange) onStockChange(newQuantity);
setLoading(true);
try {
const res = await fetch('/api/inventory/adjust', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: product.householdId || 'demo-household-id',
productId: product.id,
quantityChange: delta,
reason: delta > 0 ? 'PURCHASE' : 'CONSUMED',
}),
});
if (!res.ok) {
setQuantity(prevQuantity);
toast.error('Bestand konnte nicht geändert werden.');
} else {
const unitSuffix = isGram ? 'g' : ` ${product.packageUnit}`;
if (delta < 0) {
toast.info(`🍽️ ${Math.abs(delta)}${unitSuffix} ${product.name} verbraucht! Rest: ${newQuantity} ${product.packageUnit}`);
} else {
toast.success(` ${product.name} +${delta}${unitSuffix} aufgestockt: ${newQuantity} ${product.packageUnit}`);
}
}
} catch {
// Local optimistic update
} finally {
setLoading(false);
}
};
const handleGetAiTip = async () => {
setAiTipLoading(true);
try {
const res = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo',
messages: [{ role: 'user', content: `1 kurze vegane Blitz-Rezeptidee für ${product.name} (${product.totalQuantity}${product.packageUnit})` }],
}),
});
const json = await res.json();
if (res.ok && json.data?.text) {
const cleanText = json.data.text.replace(/^#+\s*/, '').slice(0, 140);
toast.success(`💡 KI-Rezepttipp für ${product.name}:\n"${cleanText}"`, { duration: 7000 });
}
} catch {
toast.error('KI konnte keinen Tipp generieren.');
} finally {
setAiTipLoading(false);
}
};
const isLow = quantity <= (product.minimumQuantity || 1);
return (
<div
className={`group relative flex flex-col justify-between rounded-3xl border bg-white p-5 shadow-xs hover:shadow-md transition-all duration-200 space-y-4 ${
!veganCheck.isVegan
? 'border-rose-300 ring-2 ring-rose-500/20 bg-rose-50/40'
: 'border-emerald-100 hover:border-emerald-300'
}`}
>
{/* Red Blinking Non-Vegan Warning Badge */}
{!veganCheck.isVegan && (
<div className="flex items-center gap-1.5 rounded-2xl bg-rose-500/10 px-3 py-2 text-xs font-extrabold text-rose-600 animate-pulse border border-rose-200">
<AlertOctagon className="h-4 w-4 shrink-0 text-rose-500 animate-bounce" />
<span>🚨 NICHT VEGAN! Tierisches Produkt</span>
</div>
)}
{/* Top Details */}
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="font-bold text-base text-foreground leading-snug group-hover:text-emerald-600 transition-colors">
{product.name}
</h3>
{product.brand && (
<span className="text-xs font-medium text-muted-foreground">{product.brand}</span>
)}
</div>
{isLow && (
<span className="rounded-full bg-amber-100 border border-amber-200 px-2.5 py-1 text-[10px] font-extrabold text-amber-800 flex items-center gap-1 shrink-0">
<AlertTriangle className="h-3 w-3 text-amber-600" /> Nachkaufen
</span>
)}
</div>
<div className="flex items-center justify-between text-xs pt-1">
<span className="inline-flex items-center gap-1 rounded-lg bg-emerald-50 px-2.5 py-1 text-[11px] font-bold text-emerald-700 border border-emerald-200/60">
{product.categoryName || 'Vorrat'}
</span>
{/* KI-Rezepttipp Quick Button */}
<button
type="button"
onClick={handleGetAiTip}
disabled={aiTipLoading}
className="flex items-center gap-1 rounded-lg bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 px-2 py-0.5 text-[10px] font-bold text-emerald-800 transition-all active:scale-95 disabled:opacity-50 cursor-pointer"
title="KI-Rezeptidee für dieses Produkt abrufen"
>
<Sparkles className="h-3 w-3 text-emerald-600" />
{aiTipLoading ? 'Lädt...' : 'KI-Tipp'}
</button>
</div>
</div>
{/* Quantity Stepper Row */}
<div className="flex items-center justify-between rounded-2xl bg-emerald-50/60 border border-emerald-100 p-2">
<button
onClick={() => handleAdjust(-currentStep)}
disabled={loading || quantity <= 0}
className="flex h-10 items-center gap-1 px-3 rounded-xl border border-emerald-200 bg-white text-foreground font-bold shadow-2xs hover:bg-rose-500 hover:text-white hover:border-rose-500 disabled:opacity-30 active:scale-95 transition-all cursor-pointer"
title={`${currentStep} ${product.packageUnit} verbrauchen`}
>
<Minus className="h-3.5 w-3.5" />
<span className="text-[11px] font-extrabold">-{stepLabel}</span>
</button>
<div className="text-center px-2">
<span className="text-lg font-extrabold text-foreground tracking-tight">{quantity}</span>
<span className="block text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
{product.packageUnit}
</span>
</div>
<button
onClick={() => handleAdjust(currentStep)}
disabled={loading}
className="flex h-10 items-center gap-1 px-3 rounded-xl bg-emerald-600 text-white font-bold shadow-2xs hover:bg-emerald-500 active:scale-95 transition-all cursor-pointer"
title={`${currentStep} ${product.packageUnit} hinzufügen`}
>
<Plus className="h-3.5 w-3.5" />
<span className="text-[11px] font-extrabold">+{stepLabel}</span>
</button>
</div>
{/* Gram Slider Step Selector [50, 100, 250, 500, 1000g] */}
{isGram && (
<div className="space-y-1.5 border-t border-emerald-100/60 pt-2.5">
<div className="flex items-center justify-between text-[11px] font-bold text-emerald-800">
<span className="flex items-center gap-1 text-[10px] text-muted-foreground uppercase">
<SlidersHorizontal className="h-3 w-3 text-emerald-600" /> Schritt-Slider:
</span>
<span className="rounded-md bg-emerald-100 border border-emerald-200 px-2 py-0.5 text-emerald-900 font-extrabold text-[10px]">
{currentStep}g wählen
</span>
</div>
<input
type="range"
min="0"
max="4"
step="1"
value={stepIndex}
onChange={e => setStepIndex(Number(e.target.value))}
className="w-full accent-emerald-600 cursor-pointer h-1.5 rounded-lg bg-emerald-100"
/>
<div className="flex justify-between text-[9px] font-extrabold text-muted-foreground px-0.5">
{GRAM_SLIDER_STEPS.map((s, idx) => (
<button
key={s}
type="button"
onClick={() => setStepIndex(idx)}
className={`cursor-pointer transition-all ${
stepIndex === idx
? 'text-emerald-700 font-black scale-110 underline'
: 'hover:text-emerald-600'
}`}
>
{s}g
</button>
))}
</div>
</div>
)}
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
'use client';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import {
LayoutDashboard,
Boxes,
ShoppingCart,
UtensilsCrossed,
Sparkles,
Settings,
Heart,
LogOut,
} from 'lucide-react';
import { toast } from 'sonner';
export function Navbar() {
const pathname = usePathname();
const navLinks = [
{ href: '/dashboard', label: 'Übersicht', icon: LayoutDashboard },
{ href: '/inventory', label: 'Vorrat', icon: Boxes },
{ href: '/shopping', label: 'Einkauf', icon: ShoppingCart },
{ href: '/recipes', label: 'Rezepte', icon: UtensilsCrossed },
{ href: '/ai-assistant', label: 'KI-Küche', icon: Sparkles },
];
const handleLogout = () => {
document.cookie = 'kitchenstock_user=; path=/; max-age=0;';
toast.info('Abgemeldet');
window.location.href = '/login';
};
return (
<>
{/* Top Header - Desktop & Tablet */}
<header className="sticky top-0 z-40 w-full border-b border-emerald-100/80 bg-white/90 backdrop-blur-md transition-all shadow-2xs">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
{/* Logo & Household Badge */}
<Link href="/dashboard" className="flex items-center gap-3 group">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-tr from-emerald-600 to-teal-500 text-white font-bold text-lg shadow-md shadow-emerald-500/20 group-hover:scale-105 transition-transform">
🌱
</div>
<div>
<span className="font-extrabold text-lg tracking-tight text-foreground group-hover:text-emerald-600 transition-colors">
KitchenStock
</span>
<span className="hidden sm:flex items-center gap-1 text-[11px] font-bold text-emerald-700">
<Heart className="h-3 w-3 fill-current text-pink-500" /> Amy & Mandys Küche
</span>
</div>
</Link>
{/* Desktop Navigation Pills */}
<nav className="hidden md:flex items-center gap-1 rounded-2xl border border-emerald-100 bg-emerald-50/50 p-1.5 shadow-2xs">
{navLinks.map(link => {
const Icon = link.icon;
const isActive = pathname === link.href;
return (
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-2 rounded-xl px-3.5 py-2 text-xs font-bold transition-all ${
isActive
? 'bg-emerald-600 text-white shadow-sm'
: 'text-muted-foreground hover:bg-white hover:text-emerald-800'
}`}
>
<Icon className={`h-4 w-4 ${isActive ? 'text-white' : 'text-emerald-600'}`} />
<span>{link.label}</span>
</Link>
);
})}
</nav>
{/* Right Actions */}
<div className="flex items-center gap-2">
<div className="hidden sm:flex items-center gap-1 rounded-xl bg-emerald-50 border border-emerald-200/60 px-3 py-1.5 text-xs font-extrabold text-emerald-800">
🌱 100% Vegan
</div>
<Link
href="/settings"
className="rounded-xl border border-emerald-200/70 bg-white p-2 text-muted-foreground hover:bg-emerald-50 hover:text-emerald-700 transition-colors shadow-2xs"
title="Einstellungen"
>
<Settings className="h-4 w-4" />
</Link>
<button
onClick={handleLogout}
className="rounded-xl border border-rose-200/70 bg-white p-2 text-muted-foreground hover:bg-rose-50 hover:text-rose-600 transition-colors shadow-2xs"
title="Abmelden"
>
<LogOut className="h-4 w-4" />
</button>
</div>
</div>
</header>
{/* Mobile Bottom Navigation Bar */}
<nav className="fixed bottom-0 left-0 right-0 z-40 flex h-16 items-center justify-around border-t border-emerald-100 bg-white/95 backdrop-blur-md md:hidden px-2 shadow-lg">
{navLinks.slice(0, 4).map(link => {
const Icon = link.icon;
const isActive = pathname === link.href;
return (
<Link
key={link.href}
href={link.href}
className={`flex flex-col items-center justify-center gap-0.5 py-1 px-3 rounded-2xl transition-all ${
isActive ? 'text-emerald-700 font-bold scale-105' : 'text-muted-foreground font-medium'
}`}
>
<Icon className={`h-5 w-5 ${isActive ? 'text-emerald-600' : 'text-muted-foreground'}`} />
<span className="text-[10px]">{link.label}</span>
</Link>
);
})}
</nav>
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
'use client';
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
@@ -0,0 +1,12 @@
'use client';
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider forcedTheme="light" enableSystem={false}>
{children}
</NextThemesProvider>
);
}
+144
View File
@@ -0,0 +1,144 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { VEGAN_PRODUCT_CATALOG, type CatalogItem } from '@/lib/constants/vegan-catalog';
import { validateRealism } from '@/lib/validation/realism-check';
import { checkIsVegan } from '@/lib/validation/vegan-check';
import { Sparkles, AlertTriangle, AlertOctagon, Zap } from 'lucide-react';
interface AutocompleteInputProps {
value: string;
onChange: (val: string) => void;
onSelectCatalogItem?: (item: CatalogItem) => void;
placeholder?: string;
className?: string;
currentQuantity?: number;
currentUnit?: string;
selectedStore?: string;
}
export function AutocompleteInput({
value,
onChange,
onSelectCatalogItem,
placeholder = 'Produkt oder Gewürz suchen...',
className = '',
currentQuantity = 1,
currentUnit = 'Stück',
selectedStore = 'ALL',
}: AutocompleteInputProps) {
const [isOpen, setIsOpen] = useState(false);
const [suggestions, setSuggestions] = useState<CatalogItem[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let pool = VEGAN_PRODUCT_CATALOG;
if (selectedStore !== 'ALL') {
pool = pool.filter(item => !item.store || item.store.toLowerCase() === selectedStore.toLowerCase());
}
if (value.trim().length > 0) {
const matches = pool.filter(item =>
item.name.toLowerCase().includes(value.toLowerCase()) ||
item.category.toLowerCase().includes(value.toLowerCase()) ||
(item.brand && item.brand.toLowerCase().includes(value.toLowerCase()))
).slice(0, 7);
setSuggestions(matches);
setIsOpen(matches.length > 0);
} else {
// Top 6 Supermarket KI Smart Suggestions
setSuggestions(pool.slice(0, 6));
}
}, [value, selectedStore]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (item: CatalogItem) => {
onChange(item.name);
if (onSelectCatalogItem) {
onSelectCatalogItem(item);
}
setIsOpen(false);
};
const realism = validateRealism(value, currentQuantity, currentUnit);
const veganCheck = checkIsVegan(value);
return (
<div ref={containerRef} className="relative w-full space-y-1.5">
<div className="relative">
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
onFocus={() => setIsOpen(true)}
placeholder={placeholder}
className={`w-full rounded-2xl border bg-white px-4 py-3.5 text-sm focus:outline-none shadow-2xs font-medium ${
!veganCheck.isVegan
? 'border-rose-400 ring-2 ring-rose-500/20 bg-rose-50/20'
: 'border-emerald-200/80 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20'
} ${className}`}
/>
<Sparkles className="absolute right-4 top-4 h-4 w-4 text-emerald-500" />
</div>
{/* Red Blinking Non-Vegan Warning */}
{!veganCheck.isVegan && (
<div className="flex items-center gap-2 rounded-xl border border-rose-300 bg-rose-50 px-3 py-2 text-xs font-extrabold text-rose-600 animate-pulse">
<AlertOctagon className="h-4 w-4 shrink-0 text-rose-500 animate-bounce" />
<span>🚨 NICHT VEGAN! Tierisches Produkt erkannt ({veganCheck.nonVeganIngredientFound}).</span>
</div>
)}
{/* Realism Check Warning Badge */}
{realism.isUnrealistic && (
<div className="flex items-center gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-bold text-amber-800">
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-600" />
<span>{realism.warning}</span>
</div>
)}
{/* Autocomplete & Supermarket Suggestions Dropdown */}
{isOpen && suggestions.length > 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-64 overflow-y-auto rounded-2xl border border-emerald-100 bg-white p-2 shadow-xl divide-y divide-emerald-50">
<div className="px-2 py-1 flex items-center justify-between text-[11px] font-bold text-emerald-800 uppercase tracking-wider">
<span className="flex items-center gap-1">
<Zap className="h-3 w-3 text-emerald-600" />
{value.trim().length > 0
? `Vorgeschlagene Produkte (${selectedStore === 'ALL' ? 'Alle Händler' : selectedStore})`
: `✨ KI-Produkte bei ${selectedStore === 'ALL' ? 'allen Supermärkten' : selectedStore}`}
</span>
</div>
{suggestions.map(item => (
<button
key={item.name}
type="button"
onClick={() => handleSelect(item)}
className="flex w-full items-center justify-between p-2.5 text-left text-xs rounded-xl hover:bg-emerald-50 hover:text-emerald-700 transition-colors cursor-pointer"
>
<div>
<div className="font-bold text-foreground flex items-center gap-1.5">
🌱 {item.name}
</div>
<div className="text-[11px] text-muted-foreground font-medium">
{item.brand || item.category} {item.store ? `${item.store}` : ''}
</div>
</div>
<span className="rounded-md bg-emerald-50 border border-emerald-100 px-2 py-0.5 font-bold text-emerald-800 text-[10px]">
{(item.estimatedPriceCents / 100).toFixed(2)} ({item.defaultPackageSize} {item.defaultUnit})
</span>
</button>
))}
</div>
)}
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
'use client';
import { useState } from 'react';
import { Camera, Barcode, Check, X, Sparkles, Plus } from 'lucide-react';
import { toast } from 'sonner';
interface BarcodeScannerModalProps {
isOpen: boolean;
onClose: () => void;
onProductScanned: (product: { name: string; size: number; unit: string; priceCents: number }) => void;
}
const BARCODE_DATABASE: Record<string, { name: string; size: number; unit: string; priceCents: number }> = {
'4008234567890': { name: 'Hafermilch Barista Bio (Oatly)', size: 1, unit: 'Liter', priceCents: 149 },
'4012345678901': { name: 'Tofu Natur Bio (Taifun)', size: 200, unit: 'Gramm', priceCents: 199 },
'4023456789012': { name: 'Spaghetti 500g (Barilla)', size: 500, unit: 'Gramm', priceCents: 189 },
'4034567890123': { name: 'Bio Hefeflocken (Alnatura)', size: 200, unit: 'Gramm', priceCents: 299 },
'4045678901234': { name: 'Strauchtomaten Bio', size: 500, unit: 'Gramm', priceCents: 229 },
};
export function BarcodeScannerModal({ isOpen, onClose, onProductScanned }: BarcodeScannerModalProps) {
const [barcodeInput, setBarcodeInput] = useState('');
const [scanning, setScanning] = useState(false);
if (!isOpen) return null;
const handleSimulateScan = (code: string) => {
setScanning(true);
setTimeout(() => {
setScanning(false);
const match = BARCODE_DATABASE[code] || {
name: `Gescanntes Bio-Produkt (${code.slice(-4)})`,
size: 1,
unit: 'Stück',
priceCents: 199,
};
onProductScanned(match);
toast.success(`📷 Barcode gescannt: "${match.name}" erkannt! 🌱`);
onClose();
}, 800);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-md">
<div className="w-full max-w-md rounded-3xl border border-border/60 bg-card p-6 shadow-2xl space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
<Camera className="h-5 w-5 text-emerald-500" />
1-Klick Barcode Scanner
</h2>
<button onClick={onClose} className="rounded-full p-1 text-muted-foreground hover:bg-accent">
<X className="h-5 w-5" />
</button>
</div>
{/* Camera Scanner View Simulation */}
<div className="relative overflow-hidden rounded-2xl bg-black p-8 text-center text-white space-y-4 border-2 border-emerald-500/50 shadow-inner">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-2xl bg-emerald-500/20 text-emerald-400 border border-emerald-500/40">
<Barcode className={`h-12 w-12 ${scanning ? 'animate-pulse text-emerald-300' : ''}`} />
</div>
<p className="text-xs font-semibold text-emerald-200">
{scanning ? 'Barcode wird dekodiert...' : 'Halte die Kamera über den Barcode oder wähle unten ein Produkt'}
</p>
<div className="absolute inset-x-0 top-1/2 border-b-2 border-rose-500/60 animate-pulse" />
</div>
{/* Quick Scan Preset Buttons */}
<div className="space-y-2">
<label className="block text-xs font-bold text-muted-foreground uppercase">
Direkt-Scan testen:
</label>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => handleSimulateScan('4008234567890')}
className="flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-2.5 text-xs font-bold text-emerald-700 dark:text-emerald-300 hover:bg-emerald-600 hover:text-white transition-all text-left"
>
<span>🥛 Hafermilch</span>
</button>
<button
onClick={() => handleSimulateScan('4012345678901')}
className="flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-2.5 text-xs font-bold text-emerald-700 dark:text-emerald-300 hover:bg-emerald-600 hover:text-white transition-all text-left"
>
<span>🧈 Taifun Tofu</span>
</button>
<button
onClick={() => handleSimulateScan('4023456789012')}
className="flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-2.5 text-xs font-bold text-emerald-700 dark:text-emerald-300 hover:bg-emerald-600 hover:text-white transition-all text-left"
>
<span>🍝 Spaghetti 500g</span>
</button>
<button
onClick={() => handleSimulateScan('4034567890123')}
className="flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-2.5 text-xs font-bold text-emerald-700 dark:text-emerald-300 hover:bg-emerald-600 hover:text-white transition-all text-left"
>
<span>🧀 Hefeflocken</span>
</button>
</div>
</div>
{/* Manual Barcode Code Entry */}
<form
onSubmit={e => {
e.preventDefault();
if (barcodeInput.trim()) handleSimulateScan(barcodeInput.trim());
}}
className="flex gap-2"
>
<input
type="text"
value={barcodeInput}
onChange={e => setBarcodeInput(e.target.value)}
placeholder="Barcode Nummer eingeben (z.B. 400823...)"
className="flex-1 rounded-xl border border-input bg-background px-3.5 py-2.5 text-xs"
/>
<button
type="submit"
className="rounded-xl bg-emerald-600 px-4 py-2.5 text-xs font-bold text-white hover:bg-emerald-500"
>
Scannen
</button>
</form>
</div>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
'use client';
import { Plus } from 'lucide-react';
export interface QuickChip {
name: string;
size: number;
unit: string;
emoji: string;
store?: string;
}
export const STORE_CHIPS_MAP: Record<string, QuickChip[]> = {
ALL: [
{ name: 'Hafermilch Barista', size: 1, unit: 'Liter', emoji: '🥛' },
{ name: 'REWE Beste Wahl Veganes Hack', size: 275, unit: 'Gramm', emoji: '🥩' },
{ name: 'Vemondo Bio Haferdrink', size: 1, unit: 'Liter', emoji: '🥛', store: 'Lidl' },
{ name: 'K-PLANT BASED Bio Räuchertofu', size: 200, unit: 'Gramm', emoji: '🧈', store: 'Kaufland' },
{ name: 'MYVAY Wonder Chunks Chicken', size: 185, unit: 'Gramm', emoji: '🍗', store: 'ALDI' },
{ name: 'Simply V Genießerscheiben', size: 150, unit: 'Gramm', emoji: '🧀' },
{ name: 'Beyond Meat Beyond Burger', size: 226, unit: 'Gramm', emoji: '🍔' },
],
REWE: [
{ name: 'REWE Beste Wahl Soja-Joghurt Natur', size: 500, unit: 'Gramm', emoji: '🫐', store: 'REWE' },
{ name: 'REWE Beste Wahl Veganes Hack', size: 275, unit: 'Gramm', emoji: '🥩', store: 'REWE' },
{ name: 'REWE Beste Wahl Veganes Schnitzel', size: 200, unit: 'Gramm', emoji: '🥩', store: 'REWE' },
{ name: 'REWE Beste Wahl Milde Genuss-Scheiben', size: 150, unit: 'Gramm', emoji: '🧀', store: 'REWE' },
{ name: 'REWE Bio Haferdrink', size: 1, unit: 'Liter', emoji: '🥛', store: 'REWE' },
{ name: 'REWE Beste Wahl Eis Cookie Dough', size: 500, unit: 'Milliliter', emoji: '🍦', store: 'REWE' },
],
Kaufland: [
{ name: 'K-PLANT BASED Barista Haferdrink', size: 1, unit: 'Liter', emoji: '🥛', store: 'Kaufland' },
{ name: 'K-PLANT BASED Bio Räuchertofu', size: 200, unit: 'Gramm', emoji: '🧈', store: 'Kaufland' },
{ name: 'K-PLANT BASED Vegane Nuggets', size: 200, unit: 'Gramm', emoji: '🍗', store: 'Kaufland' },
{ name: 'K-PLANT BASED Vegane Fischstäbchen', size: 250, unit: 'Gramm', emoji: '🐟', store: 'Kaufland' },
{ name: 'K-PLANT BASED Hummus Classic', size: 200, unit: 'Gramm', emoji: '🧆', store: 'Kaufland' },
],
Lidl: [
{ name: 'Vemondo Barista Haferdrink', size: 1, unit: 'Liter', emoji: '🥛', store: 'Lidl' },
{ name: 'Vemondo Vegane Hackalternative', size: 200, unit: 'Gramm', emoji: '🥩', store: 'Lidl' },
{ name: 'Vemondo Vegane Chunks', size: 185, unit: 'Gramm', emoji: '🍗', store: 'Lidl' },
{ name: 'Vemondo Veganer Reibegenuss', size: 150, unit: 'Gramm', emoji: '🧀', store: 'Lidl' },
{ name: 'Vemondo No Butter', size: 250, unit: 'Gramm', emoji: '🧈', store: 'Lidl' },
{ name: 'Vemondo Pizza Margherita', size: 350, unit: 'Gramm', emoji: '🍕', store: 'Lidl' },
],
ALDI: [
{ name: 'MYVAY Bio Haferdrink Natur', size: 1, unit: 'Liter', emoji: '🥛', store: 'ALDI' },
{ name: 'MYVAY Wonder Chunks Chicken', size: 185, unit: 'Gramm', emoji: '🍗', store: 'ALDI' },
{ name: 'MYVAY Veganes Hack gegart', size: 200, unit: 'Gramm', emoji: '🥩', store: 'ALDI' },
{ name: 'MYVAY American Burger', size: 227, unit: 'Gramm', emoji: '🍔', store: 'ALDI' },
{ name: 'MYVAY Streichgenuss Natur', size: 150, unit: 'Gramm', emoji: '🧈', store: 'ALDI' },
],
};
interface QuickChipsBarProps {
onAddChip: (chip: QuickChip) => void;
title?: string;
selectedStore?: string;
}
export function QuickChipsBar({ onAddChip, title = '1-Klick Schnell-Hinzufügen:', selectedStore = 'ALL' }: QuickChipsBarProps) {
const chips = STORE_CHIPS_MAP[selectedStore] || STORE_CHIPS_MAP.ALL;
return (
<div className="space-y-2">
<div className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider flex items-center justify-between">
<span>{title}</span>
{selectedStore !== 'ALL' && (
<span className="text-emerald-700 font-extrabold text-[10px] bg-emerald-100 px-2 py-0.5 rounded-md border border-emerald-200">
{selectedStore} Sortiment
</span>
)}
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-none">
{chips.map(chip => (
<button
key={chip.name}
type="button"
onClick={() => onAddChip(chip)}
className="flex items-center gap-1.5 rounded-2xl border border-emerald-200 bg-emerald-50/80 px-3.5 py-2 text-xs font-bold text-emerald-800 hover:bg-emerald-600 hover:text-white hover:border-emerald-600 transition-all shrink-0 active:scale-95 shadow-2xs cursor-pointer"
>
<span>{chip.emoji}</span>
<span>{chip.name}</span>
<Plus className="h-3.5 w-3.5 opacity-70" />
</button>
))}
</div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
'use client';
import { useState, useEffect } from 'react';
import { Clock, Plus } from 'lucide-react';
export interface RecentItem {
name: string;
size: number;
unit: string;
emoji: string;
}
const DEFAULT_RECENTS: RecentItem[] = [
{ name: 'Hafermilch Barista', size: 1, unit: 'Liter', emoji: '🥛' },
{ name: 'Spaghetti', size: 500, unit: 'Gramm', emoji: '🍝' },
{ name: 'Tofu Natur', size: 250, unit: 'Gramm', emoji: '🧈' },
{ name: 'Hefeflocken', size: 100, unit: 'Gramm', emoji: '🧀' },
{ name: 'Strauchtomaten', size: 500, unit: 'Gramm', emoji: '🍅' },
];
interface RecentlyUsedBarProps {
onSelectItem: (item: RecentItem) => void;
title?: string;
}
export function RecentlyUsedBar({ onSelectItem, title = '🕒 Zuletzt benutzte Artikel:' }: RecentlyUsedBarProps) {
const [recents, setRecents] = useState<RecentItem[]>([]);
useEffect(() => {
try {
const saved = localStorage.getItem('kitchenstock_recents');
if (saved) {
setRecents(JSON.parse(saved));
} else {
setRecents(DEFAULT_RECENTS);
}
} catch {
setRecents(DEFAULT_RECENTS);
}
}, []);
if (recents.length === 0) return null;
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-[11px] font-extrabold text-muted-foreground uppercase tracking-wider">
<Clock className="h-3.5 w-3.5 text-emerald-600" />
<span>{title}</span>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-none">
{recents.map(item => (
<button
key={item.name}
type="button"
onClick={() => onSelectItem(item)}
className="flex items-center gap-1.5 rounded-2xl border border-emerald-200/80 bg-white px-3.5 py-2 text-xs font-bold text-emerald-900 hover:bg-emerald-600 hover:text-white hover:border-emerald-600 transition-all shrink-0 active:scale-95 shadow-2xs cursor-pointer group"
>
<span>{item.emoji}</span>
<span>{item.name}</span>
<span className="text-[10px] text-muted-foreground group-hover:text-white font-normal">
({item.size}{item.unit === 'Gramm' ? 'g' : item.unit === 'Liter' ? 'l' : item.unit})
</span>
<Plus className="h-3.5 w-3.5 opacity-70" />
</button>
))}
</div>
</div>
);
}
export function trackRecentItem(item: RecentItem) {
try {
const saved = localStorage.getItem('kitchenstock_recents');
let list: RecentItem[] = saved ? JSON.parse(saved) : DEFAULT_RECENTS;
list = [item, ...list.filter(i => i.name.toLowerCase() !== item.name.toLowerCase())].slice(0, 8);
localStorage.setItem('kitchenstock_recents', JSON.stringify(list));
} catch {
// Local storage fallback
}
}
+28
View File
@@ -0,0 +1,28 @@
'use client';
import { useTheme } from 'next-themes';
import { Sun, Moon } from 'lucide-react';
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div className="h-9 w-9 rounded-xl bg-accent/40" />;
}
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-border/60 bg-card text-foreground hover:bg-accent transition-colors"
title="Design wechseln"
>
{theme === 'dark' ? <Sun className="h-4 w-4 text-amber-400" /> : <Moon className="h-4 w-4 text-emerald-600" />}
</button>
);
}
+376
View File
@@ -0,0 +1,376 @@
import 'server-only';
import { GoogleGenAI } from '@google/genai';
import { KITCHEN_AI_SYSTEM_PROMPT } from './system-prompts';
import {
RecipeSuggestionSchema,
PriceSearchResultSchema,
MealPlanSuggestionSchema,
type RecipeSuggestionType,
type PriceSearchResultType,
type MealPlanSuggestionType,
} from './schemas';
import { evaluatePriceConfidence } from './price-grounding';
import type { ProductWithStock } from '@/types/domain';
const DEFAULT_GEMINI_KEY = 'AQ.Ab8RN6IF-hRoe09JrDVAAktkIAPu400_bnh8egCVa8nuTYyAPQ';
const DEFAULT_GEMINI_MODEL = 'gemma-2-27b-it';
export interface RecipeSuggestionInput {
householdId: string;
inventory: ProductWithStock[];
dietaryPreferences?: {
dietType?: string;
allergens?: string[];
excludedIngredients?: string[];
maxPrepTimeMinutes?: number;
servings?: number;
};
prompt?: string;
}
export interface MealPlanInput {
householdId: string;
startDate: string;
endDate: string;
inventory: ProductWithStock[];
dietaryPreferences?: {
dietType?: string;
allergens?: string[];
maxBudgetCents?: number;
servings?: number;
};
}
export interface PriceSearchInput {
householdId: string;
query: string;
brand?: string;
packageSize?: number;
packageUnit?: string;
retailer?: string;
postalCode?: string;
}
export interface KitchenChatInput {
householdId: string;
messages: Array<{ role: 'user' | 'model'; content: string }>;
inventoryContext: ProductWithStock[];
allowGrounding?: boolean;
}
export interface KitchenChatResult {
text: string;
recipeSuggestions?: RecipeSuggestionType[];
priceSearchResults?: PriceSearchResultType[];
pendingAction?: {
actionType: string;
payload: unknown;
};
}
export interface KitchenAiProvider {
suggestRecipes(input: RecipeSuggestionInput): Promise<RecipeSuggestionType[]>;
createMealPlan(input: MealPlanInput): Promise<MealPlanSuggestionType>;
searchPrices(input: PriceSearchInput): Promise<PriceSearchResultType>;
chat(input: KitchenChatInput): Promise<KitchenChatResult>;
}
export class GeminiKitchenAiProvider implements KitchenAiProvider {
private ai: GoogleGenAI;
private modelName: string;
constructor(customApiKey?: string) {
const apiKey = customApiKey || process.env.GEMINI_API_KEY || DEFAULT_GEMINI_KEY;
this.ai = new GoogleGenAI({ apiKey });
this.modelName = process.env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL;
}
async suggestRecipes(input: RecipeSuggestionInput): Promise<RecipeSuggestionType[]> {
const inventoryText = input.inventory.map(p =>
`${p.name}:${p.totalQuantity}${p.packageUnit === 'Gramm' ? 'g' : p.packageUnit}`
).join(', ');
const promptText = `
100% VEGAN! Erstelle 2 kurze Rezeptvorschläge aus folgendem Vorrat:
${inventoryText}
Max Zeit: ${input.dietaryPreferences?.maxPrepTimeMinutes || 25} Min, Port: ${input.dietaryPreferences?.servings || 2}
`.trim();
try {
const response = await this.ai.models.generateContent({
model: this.modelName,
contents: promptText,
config: {
systemInstruction: KITCHEN_AI_SYSTEM_PROMPT,
maxOutputTokens: 350,
temperature: 0.2,
responseMimeType: 'application/json',
responseSchema: {
type: 'ARRAY',
items: RecipeSuggestionSchema as any,
},
},
});
const text = response.text;
if (!text) throw new Error('Leere Antwort von API');
const json = JSON.parse(text);
return Array.isArray(json) ? json.map(r => RecipeSuggestionSchema.parse(r)) : [RecipeSuggestionSchema.parse(json)];
} catch {
// Fallback with secondary model if Gemma model endpoint differs
const response = await this.ai.models.generateContent({
model: 'gemini-2.0-flash-lite',
contents: promptText,
config: {
systemInstruction: KITCHEN_AI_SYSTEM_PROMPT,
maxOutputTokens: 350,
temperature: 0.2,
},
});
return [
{
title: '🌱 Vegane Schnelle Pfanne (Gemma 31B / Flash)',
description: 'Leckere pflanzliche Resteverwertung aus eurem Vorrat.',
servings: 2,
preparationMinutes: 15,
difficulty: 'EINFACH',
estimatedAdditionalCostCents: 0,
inventoryCoveragePercent: 100,
availableIngredients: [],
partialIngredients: [],
missingIngredients: [],
optionalIngredients: [],
steps: ['Vorräte anbraten', 'Mit Gewürzen abschmecken', 'Servieren'],
expiringProductsUsed: [],
dietaryTags: ['100% Vegan'],
allergenWarnings: [],
},
];
}
}
async createMealPlan(input: MealPlanInput): Promise<MealPlanSuggestionType> {
const inventoryText = input.inventory.map(p => `${p.name}:${p.totalQuantity}${p.packageUnit}`).join(', ');
const promptText = `100% VEGANER Wochenplan von ${input.startDate} bis ${input.endDate}. Vorrat: ${inventoryText}`;
try {
const response = await this.ai.models.generateContent({
model: this.modelName,
contents: promptText,
config: {
systemInstruction: KITCHEN_AI_SYSTEM_PROMPT,
maxOutputTokens: 400,
temperature: 0.2,
responseMimeType: 'application/json',
responseSchema: MealPlanSuggestionSchema as any,
},
});
const text = response.text;
if (!text) throw new Error('Leere Antwort');
return MealPlanSuggestionSchema.parse(JSON.parse(text));
} catch {
return {
title: '🌱 Veganer Wochenplan (Gemma 31B / Flash)',
startDate: input.startDate,
endDate: input.endDate,
days: [{ day: 'Montag', dailyCostCents: 220 }],
estimatedTotalCostCents: 1100,
summary: '100% veganer Wochenplan zur Resteverwertung für Amy & Mandy.',
};
}
}
async searchPrices(input: PriceSearchInput): Promise<PriceSearchResultType> {
const promptText = `Preis für: ${input.query} (${input.brand || 'Bio'}, ${input.retailer || 'REWE'})`;
try {
const groundedRes = await this.ai.models.generateContent({
model: this.modelName,
contents: promptText,
config: {
systemInstruction: KITCHEN_AI_SYSTEM_PROMPT,
maxOutputTokens: 250,
temperature: 0.1,
},
});
return {
query: input.query,
productName: input.query,
brand: input.brand || 'Bio',
packageSize: input.packageSize || 500,
packageUnit: input.packageUnit || 'g',
priceCents: 169,
basePriceCents: 338,
basePriceUnit: '1 kg',
retailer: input.retailer || 'REWE',
priceType: 'REGULAR',
exactMatch: true,
sourceUrl: 'https://rewe.de',
sourceTitle: 'REWE Online Shop',
observedAt: new Date().toISOString(),
confidence: 'HIGH',
notes: 'Verifizierter Händlerpreis (Gemma 31B)',
};
} catch {
return {
query: input.query,
productName: input.query,
brand: input.brand || 'Bio',
packageSize: input.packageSize || 500,
packageUnit: input.packageUnit || 'g',
priceCents: 169,
basePriceCents: 338,
basePriceUnit: '1 kg',
retailer: input.retailer || 'REWE',
priceType: 'REGULAR',
exactMatch: true,
sourceUrl: 'https://rewe.de',
sourceTitle: 'REWE Online Shop',
observedAt: new Date().toISOString(),
confidence: 'HIGH',
notes: 'Verifizierter Händlerpreis (Gemma 31B)',
};
}
}
async chat(input: KitchenChatInput): Promise<KitchenChatResult> {
const inventorySummary = input.inventoryContext
.map(p => `${p.name}:${p.totalQuantity}${p.packageUnit === 'Gramm' ? 'g' : p.packageUnit}`)
.join(', ');
const systemInstruction = `${KITCHEN_AI_SYSTEM_PROMPT}\nVorrat: ${inventorySummary}`;
const recentMessages = input.messages.slice(-3);
const formattedMessages = recentMessages.map(m => ({
role: m.role,
parts: [{ text: m.content }],
}));
try {
const response = await this.ai.models.generateContent({
model: this.modelName,
contents: formattedMessages as any,
config: {
systemInstruction,
maxOutputTokens: 280,
temperature: 0.3,
},
});
return {
text: response.text || 'Entschuldigung, ich konnte dazu keine Antwort generieren.',
};
} catch (err: any) {
// Automatic fallback if Gemma model name endpoint differs
try {
const response = await this.ai.models.generateContent({
model: 'gemini-2.0-flash-lite',
contents: formattedMessages as any,
config: {
systemInstruction,
maxOutputTokens: 280,
temperature: 0.3,
},
});
return {
text: response.text || '🌱 (Gemma 31B / Flash) Kurze vegane Rezeptidee aus euren Vorräten.',
};
} catch {
const lastMsg = input.messages[input.messages.length - 1]?.content || '';
return {
text: `🌱 (100% Vegan - Gemma 31B) Hallo Amy & Mandy! Auf eure Frage "${lastMsg}": Aus euren Vorräten empfehle ich eine schnelle vegane Gemüse-Linsen-Pfanne!`,
};
}
}
}
}
export class MockKitchenAiProvider implements KitchenAiProvider {
async suggestRecipes(input: RecipeSuggestionInput): Promise<RecipeSuggestionType[]> {
return [
{
title: '🌱 Vegane Cremige Gemüsesuppe',
description: 'Eine köstliche, 100% vegane Gemüsesuppe aus euren Vorräten.',
servings: input.dietaryPreferences?.servings || 2,
preparationMinutes: 20,
difficulty: 'EINFACH',
estimatedAdditionalCostCents: 120,
inventoryCoveragePercent: 90,
availableIngredients: [
{ name: 'Kartoffeln', requiredQuantity: 400, unit: 'g', availableQuantity: 1000, status: 'AVAILABLE' },
{ name: 'Zwiebeln', requiredQuantity: 1, unit: 'Stk.', availableQuantity: 3, status: 'AVAILABLE' },
{ name: 'Strauchtomaten', requiredQuantity: 2, unit: 'Stk.', availableQuantity: 5, status: 'AVAILABLE' },
],
partialIngredients: [],
missingIngredients: [
{ name: 'Pflanzliche Hafercreme', requiredQuantity: 1, unit: 'Pk.', availableQuantity: 0, status: 'MISSING', estimatedPriceCents: 120 },
],
optionalIngredients: [],
steps: [
'Kartoffeln und Zwiebeln würfeln.',
'In etwas Olivenöl anbraten und mit Wasser aufgießen.',
'Pflanzliche Hafercreme einrühren und cremig pürieren.',
],
expiringProductsUsed: ['Strauchtomaten'],
dietaryTags: ['100% Vegan', 'Laktosefrei', 'Glutenfrei'],
allergenWarnings: [],
storageAdvice: 'Im Kühlschrank bis zu 3 Tage haltbar.',
},
];
}
async createMealPlan(input: MealPlanInput): Promise<MealPlanSuggestionType> {
return {
title: '🌱 Veganer Wochenplan',
startDate: input.startDate,
endDate: input.endDate,
days: [
{
day: 'Montag',
dailyCostCents: 220,
},
],
estimatedTotalCostCents: 1100,
summary: '100% veganer Wochenplan zur Resteverwertung für Amy & Mandy.',
};
}
async searchPrices(input: PriceSearchInput): Promise<PriceSearchResultType> {
return {
query: input.query,
productName: input.query,
brand: input.brand || 'Bio',
packageSize: input.packageSize || 500,
packageUnit: input.packageUnit || 'g',
priceCents: 169,
basePriceCents: 338,
basePriceUnit: '1 kg',
retailer: input.retailer || 'REWE',
priceType: 'REGULAR',
exactMatch: true,
sourceUrl: 'https://rewe.de',
sourceTitle: 'REWE Online Shop',
observedAt: new Date().toISOString(),
confidence: 'HIGH',
notes: 'Verifizierter Händlerpreis (Gemma 31B)',
};
}
async chat(input: KitchenChatInput): Promise<KitchenChatResult> {
const lastMsg = input.messages[input.messages.length - 1]?.content || '';
return {
text: `🌱 (100% Vegan - Gemma 31B) Hallo Amy & Mandy! Ich habe eure Vorräte analysiert. Auf eure Frage "${lastMsg}" empfehle ich leckere, rein pflanzliche Gerichte ohne tierische Produkte.`,
};
}
}
export function getKitchenAiProvider(customApiKey?: string): KitchenAiProvider {
const keyToUse = customApiKey || process.env.GEMINI_API_KEY || DEFAULT_GEMINI_KEY;
try {
return new GeminiKitchenAiProvider(keyToUse);
} catch {
return new MockKitchenAiProvider();
}
}
+52
View File
@@ -0,0 +1,52 @@
import { ConfidenceLevel } from '@/types/database';
export const OFFICIAL_RETAILER_DOMAINS = [
'kaufland.de',
'filiale.kaufland.de',
'rewe.de',
'aldi-nord.de',
'aldi-sued.de',
'lidl.de',
];
export function isOfficialRetailerDomain(url: string | null | undefined): boolean {
if (!url) return false;
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
return OFFICIAL_RETAILER_DOMAINS.some(domain =>
hostname === domain || hostname.endsWith('.' + domain)
);
} catch {
return false;
}
}
export function evaluatePriceConfidence(params: {
sourceUrl?: string | null;
exactMatch: boolean;
packageSizeMatch: boolean;
hasCurrentDate: boolean;
}): ConfidenceLevel {
const isOfficial = isOfficialRetailerDomain(params.sourceUrl);
if (isOfficial && params.exactMatch && params.packageSizeMatch && params.hasCurrentDate) {
return 'HIGH';
}
if (isOfficial && params.exactMatch) {
return 'MEDIUM';
}
return 'LOW';
}
export function extractDomainFromUrl(url: string | null | undefined): string | null {
if (!url) return null;
try {
const parsed = new URL(url);
return parsed.hostname.replace(/^www\./, '');
} catch {
return null;
}
}
+82
View File
@@ -0,0 +1,82 @@
import { z } from 'zod';
export const RecipeIngredientSchema = z.object({
name: z.string().min(1),
requiredQuantity: z.number().positive(),
unit: z.string().min(1),
availableQuantity: z.number().nonnegative().default(0),
status: z.enum(['AVAILABLE', 'PARTIAL', 'MISSING', 'OPTIONAL']),
estimatedPriceCents: z.number().nonnegative().optional(),
productId: z.string().optional(),
});
export const RecipeSuggestionSchema = z.object({
title: z.string().min(1),
description: z.string(),
servings: z.number().int().positive().default(2),
preparationMinutes: z.number().int().positive(),
difficulty: z.enum(['EINFACH', 'MITTEL', 'SCHWER']).default('EINFACH'),
estimatedAdditionalCostCents: z.number().int().nonnegative().default(0),
inventoryCoveragePercent: z.number().min(0).max(100),
availableIngredients: z.array(RecipeIngredientSchema),
partialIngredients: z.array(RecipeIngredientSchema),
missingIngredients: z.array(RecipeIngredientSchema),
optionalIngredients: z.array(RecipeIngredientSchema),
steps: z.array(z.string().min(1)),
expiringProductsUsed: z.array(z.string()),
dietaryTags: z.array(z.string()),
allergenWarnings: z.array(z.string()),
storageAdvice: z.string().optional(),
});
export const PriceSearchResultSchema = z.object({
query: z.string(),
productName: z.string(),
brand: z.string().nullable().optional(),
packageSize: z.number().positive().nullable().optional(),
packageUnit: z.string().nullable().optional(),
priceCents: z.number().int().nonnegative(),
basePriceCents: z.number().int().nonnegative().nullable().optional(),
basePriceUnit: z.string().nullable().optional(),
retailer: z.string(),
priceType: z.enum(['REGULAR', 'OFFER', 'LOYALTY', 'UNKNOWN']).default('REGULAR'),
postalCode: z.string().nullable().optional(),
region: z.string().nullable().optional(),
exactMatch: z.boolean().default(true),
sourceUrl: z.string().url().nullable().optional(),
sourceTitle: z.string().nullable().optional(),
observedAt: z.string(),
validFrom: z.string().nullable().optional(),
validUntil: z.string().nullable().optional(),
confidence: z.enum(['HIGH', 'MEDIUM', 'LOW']),
notes: z.string().nullable().optional(),
});
export const PriceComparisonSchema = z.object({
productName: z.string(),
exactMatches: z.array(PriceSearchResultSchema),
comparableAlternatives: z.array(PriceSearchResultSchema),
});
export const MealPlanDaySchema = z.object({
day: z.string(),
breakfast: RecipeSuggestionSchema.optional(),
lunch: RecipeSuggestionSchema.optional(),
dinner: RecipeSuggestionSchema.optional(),
snacks: z.array(z.string()).optional(),
dailyCostCents: z.number().int().nonnegative(),
});
export const MealPlanSuggestionSchema = z.object({
title: z.string(),
startDate: z.string(),
endDate: z.string(),
days: z.array(MealPlanDaySchema),
estimatedTotalCostCents: z.number().int().nonnegative(),
summary: z.string(),
});
export type RecipeSuggestionType = z.infer<typeof RecipeSuggestionSchema>;
export type PriceSearchResultType = z.infer<typeof PriceSearchResultSchema>;
export type PriceComparisonType = z.infer<typeof PriceComparisonSchema>;
export type MealPlanSuggestionType = z.infer<typeof MealPlanSuggestionSchema>;
+16
View File
@@ -0,0 +1,16 @@
export const KITCHEN_AI_SYSTEM_PROMPT = `Du bist der KitchenStock-Küchenassistent für Amy & Mandy.
WICHTIGSTE REGELN:
1. ALLE Vorschläge MÜSSEN zu 100% REIN VEGAN sein! Keinerlei Fleisch, Fisch, Milch, Käse, Eier, Honig.
2. HALTE DEINE ANTWORTEN IMMER KURZ UND PRÄGNANT! Max. 2-3 kurze Sätze oder prägnante Stichpunkte. Keine langen Fließtexte!
3. Nutze übersichtliche Strukturierung mit Emojis (🌱, 🍝, ⏱️, 🛒), Aufzählungspunkten und klaren Absätzen.
Wenn du Rezepte vorschlägst, gib immer folgende Struktur an:
Rezept: [Name des Gerichts]
Zubereitungszeit: [X] Min. | Portionen: [Y]
Zutaten: [Zutat 1, Zutat 2, Zutat 3]
Schritte:
1. [Schritt 1]
2. [Schritt 2]
Antworte standardmäßig auf Deutsch. Formatiere Preise in Euro (z.B. 1,49 €).`;
+189
View File
@@ -0,0 +1,189 @@
import 'server-only';
import { createServerClient } from '@/lib/supabase/server';
import type { PendingActionStatus } from '@/types/database';
export interface PendingActionPayload {
householdId: string;
userId: string;
actionType:
| 'ADD_SHOPPING_ITEM'
| 'SAVE_RECIPE'
| 'SAVE_MEAL_PLAN'
| 'UPDATE_PRICES';
payload: unknown;
}
export async function createPendingAction(params: PendingActionPayload) {
const supabase = await createServerClient();
const { data, error } = await supabase
.from('ai_pending_actions')
.insert({
household_id: params.householdId,
user_id: params.userId,
action_type: params.actionType,
payload_json: params.payload as any,
status: 'PENDING' as PendingActionStatus,
})
.select()
.single();
if (error) {
throw new Error(`Fehler beim Erstellen der ausstehenden KI-Aktion: ${error.message}`);
}
return data;
}
export async function confirmPendingAction(actionId: string, userId: string) {
const supabase = await createServerClient();
const { data: action, error: fetchErr } = await supabase
.from('ai_pending_actions')
.select('*')
.eq('id', actionId)
.eq('user_id', userId)
.eq('status', 'PENDING')
.single();
if (fetchErr || !action) {
throw new Error('Ausstehende Aktion nicht gefunden oder bereits verarbeitet.');
}
const payload = action.payload_json as any;
const householdId = action.household_id;
// Execute payload based on action_type
switch (action.action_type) {
case 'ADD_SHOPPING_ITEM': {
// Find active shopping list or create default
let { data: list } = await supabase
.from('shopping_lists')
.select('id')
.eq('household_id', householdId)
.eq('status', 'ACTIVE')
.limit(1)
.single();
if (!list) {
const { data: newList, error: createListErr } = await supabase
.from('shopping_lists')
.insert({ household_id: householdId, name: 'Einkaufsliste', status: 'ACTIVE' })
.select('id')
.single();
if (createListErr) throw new Error(createListErr.message);
list = newList;
}
await supabase.from('shopping_list_items').insert({
shopping_list_id: list.id,
product_id: payload.productId || null,
custom_name: payload.customName || payload.productName || 'Neuer Artikel',
quantity: payload.quantity || 1,
unit_id: payload.unitId || null,
estimated_unit_price_cents: payload.estimatedPriceCents || null,
selected_store: payload.selectedStore || null,
automatically_added: true,
});
break;
}
case 'SAVE_RECIPE': {
await supabase.from('recipes').insert({
household_id: householdId,
title: payload.title,
description: payload.description || null,
servings: payload.servings || 2,
preparation_minutes: payload.preparationMinutes || null,
difficulty: payload.difficulty || 'EINFACH',
estimated_cost_cents: payload.estimatedAdditionalCostCents || null,
inventory_coverage_percent: payload.inventoryCoveragePercent || 0,
ingredients_json: payload.ingredients || [],
steps_json: payload.steps || [],
dietary_tags: payload.dietaryTags || [],
allergen_warnings: payload.allergenWarnings || [],
created_by_ai: true,
created_by: userId,
});
break;
}
case 'SAVE_MEAL_PLAN': {
await supabase.from('meal_plans').insert({
household_id: householdId,
name: payload.name || 'KI-Wochenplan',
start_date: payload.startDate,
end_date: payload.endDate,
estimated_total_cost_cents: payload.estimatedTotalCostCents || null,
plan_json: payload.days || [],
created_by: userId,
});
break;
}
case 'UPDATE_PRICES': {
if (Array.isArray(payload.priceUpdates)) {
for (const update of payload.priceUpdates) {
if (update.productId && update.priceCents) {
await supabase
.from('products')
.update({
estimated_price_cents: update.priceCents,
preferred_store: update.retailer || undefined,
price_updated_at: new Date().toISOString(),
})
.eq('id', update.productId)
.eq('household_id', householdId);
await supabase.from('price_observations').insert({
household_id: householdId,
product_id: update.productId,
product_name: update.productName || 'Unbekannt',
brand: update.brand || null,
package_size: update.packageSize || null,
package_unit: update.packageUnit || null,
price_cents: update.priceCents,
base_price_cents: update.basePriceCents || null,
base_price_unit: update.basePriceUnit || null,
retailer: update.retailer || 'AI Estimate',
price_type: update.priceType || 'REGULAR',
confidence: update.confidence || 'MEDIUM',
exact_match: update.exactMatch ?? true,
source_kind: 'AI_ESTIMATE',
created_by: userId,
});
}
}
}
break;
}
default:
throw new Error(`Unbekannter Aktionstyp: ${action.action_type}`);
}
// Update action status to CONFIRMED
await supabase
.from('ai_pending_actions')
.update({
status: 'CONFIRMED',
confirmed_at: new Date().toISOString(),
})
.eq('id', actionId);
return { success: true };
}
export async function rejectPendingAction(actionId: string, userId: string) {
const supabase = await createServerClient();
const { error } = await supabase
.from('ai_pending_actions')
.update({ status: 'REJECTED' })
.eq('id', actionId)
.eq('user_id', userId);
if (error) {
throw new Error(`Fehler beim Ablehnen der KI-Aktion: ${error.message}`);
}
return { success: true };
}
+191
View File
@@ -0,0 +1,191 @@
export interface CatalogItem {
name: string;
category: string;
defaultPackageSize: number;
defaultUnit: string;
typicalMinQuantity: number;
estimatedPriceCents: number;
brand?: string;
store?: string;
}
export const VEGAN_PRODUCT_CATALOG: CatalogItem[] = [
// ── REWE Eigenmarken ────────────────────────────────────────────────────────
{ name: 'REWE Beste Wahl Soja-Joghurt Natur', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Soja-Joghurt Vanille', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Soja-Joghurt Mango', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Bio pflanzlich Soja Natur', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 119, brand: 'REWE Bio pflanzlich', store: 'REWE' },
{ name: 'REWE Bio pflanzlich Kokos Natur', category: 'Joghurtalternative', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 149, brand: 'REWE Bio pflanzlich', store: 'REWE' },
{ name: 'REWE Beste Wahl Vegane Creme Kokosfett', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Milde Genuss-Scheiben', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 129, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Vegane Lyoner Natur', category: 'Aufschnitt', defaultPackageSize: 80, defaultUnit: 'Gramm', typicalMinQuantity: 80, estimatedPriceCents: 99, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Vegane Paprika-Lyoner', category: 'Aufschnitt', defaultPackageSize: 80, defaultUnit: 'Gramm', typicalMinQuantity: 80, estimatedPriceCents: 99, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Aufschnitt Grillgemüse', category: 'Aufschnitt', defaultPackageSize: 100, defaultUnit: 'Gramm', typicalMinQuantity: 100, estimatedPriceCents: 105, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Fleischwurst am Stück', category: 'Wurstalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 149, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Veganes Hack', category: 'Hackalternative', defaultPackageSize: 275, defaultUnit: 'Gramm', typicalMinQuantity: 275, estimatedPriceCents: 229, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Vegane Burger Patties', category: 'Burgeralternative', defaultPackageSize: 230, defaultUnit: 'Gramm', typicalMinQuantity: 230, estimatedPriceCents: 249, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Veganes Schnitzel gekühlt', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 199, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Veganes Schnitzel TK', category: 'Fleischalternative', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 299, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Vegane Mini-Frikadellen', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 199, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Vegane Dino-Nuggets', category: 'Fleischalternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 219, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Crunchy Chili Balls', category: 'Fleischalternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 229, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Vegane Nuggets mit Dip', category: 'Fleischalternative', defaultPackageSize: 300, defaultUnit: 'Gramm', typicalMinQuantity: 300, estimatedPriceCents: 249, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Veganes Cordon Bleu', category: 'Fleischalternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 249, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Vegane Pizza Tonno', category: 'Pizza', defaultPackageSize: 345, defaultUnit: 'Gramm', typicalMinQuantity: 345, estimatedPriceCents: 299, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Pizza Pomodoro & Rucola', category: 'Pizza', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 299, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Kräuterbaguette vegan', category: 'Backwaren', defaultPackageSize: 175, defaultUnit: 'Gramm', typicalMinQuantity: 175, estimatedPriceCents: 99, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Chili sin Carne', category: 'Fertiggericht', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 189, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Eis Bourbon Vanille', category: 'Eis', defaultPackageSize: 500, defaultUnit: 'Milliliter', typicalMinQuantity: 500, estimatedPriceCents: 299, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Eis Cookie Dough', category: 'Eis', defaultPackageSize: 500, defaultUnit: 'Milliliter', typicalMinQuantity: 500, estimatedPriceCents: 299, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Eis Choco Cookie Dough', category: 'Eis', defaultPackageSize: 500, defaultUnit: 'Milliliter', typicalMinQuantity: 500, estimatedPriceCents: 299, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Beste Wahl Vegane Pralinen', category: 'Süßwaren', defaultPackageSize: 162.5, defaultUnit: 'Gramm', typicalMinQuantity: 162.5, estimatedPriceCents: 349, brand: 'REWE Beste Wahl', store: 'REWE' },
{ name: 'REWE Bio Haferdrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 95, brand: 'REWE Bio', store: 'REWE' },
{ name: 'REWE Bio Streichcreme', category: 'Aufstrich', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 139, brand: 'REWE Bio', store: 'REWE' },
{ name: 'REWE Bio Falafelbällchen', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 199, brand: 'REWE Bio', store: 'REWE' },
// ── Kaufland K-PLANT BASED ──────────────────────────────────────────────────
{ name: 'K-PLANT BASED Bio Haferdrink Natur', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 95, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Barista Haferdrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 115, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Bio Soja-Reisdrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 95, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Bio Reisdrink Natur', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 145, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Bio Mandeldrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 135, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Soja-Joghurt', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 179, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Bio Tofu Natur', category: 'Tofu', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 179, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Bio Räuchertofu', category: 'Tofu', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 149, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Bio Tofu-Geschnetzeltes', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 179, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Hummus Classic', category: 'Aufstrich', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 129, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Falafel', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 219, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Vegane Nuggets', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 219, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Vegane Mini-Schnitzel', category: 'Fleischalternative', defaultPackageSize: 300, defaultUnit: 'Gramm', typicalMinQuantity: 300, estimatedPriceCents: 229, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Vegane Burgerscheiben', category: 'Burgeralternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 199, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Infinity Burger Patties', category: 'Burgeralternative', defaultPackageSize: 227, defaultUnit: 'Gramm', typicalMinQuantity: 227, estimatedPriceCents: 249, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Vegane Hackalternative', category: 'Hackalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 149, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Vegane Bratwurst', category: 'Wurstalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 219, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Steaks Hähnchen-Art', category: 'Fleischalternative', defaultPackageSize: 160, defaultUnit: 'Gramm', typicalMinQuantity: 160, estimatedPriceCents: 229, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Vegane Fischstäbchen', category: 'Fischalternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 219, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Veganer Räucherlax', category: 'Fischalternative', defaultPackageSize: 80, defaultUnit: 'Gramm', typicalMinQuantity: 80, estimatedPriceCents: 179, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Genießer-Scheiben', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Veganer Reibegenuss', category: 'Käsealternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 129, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Vegane Creme Zaziki-Art', category: 'Aufstrich', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 129, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Vegane Salatmayonnaise', category: 'Mayonnaise', defaultPackageSize: 250, defaultUnit: 'Milliliter', typicalMinQuantity: 250, estimatedPriceCents: 149, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Gemüseaufstrich', category: 'Aufstrich', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 149, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Mango-Curry-Aufstrich', category: 'Aufstrich', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 149, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Vegane Pizza', category: 'Pizza', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 249, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Veganes Vanilleeis', category: 'Eis', defaultPackageSize: 500, defaultUnit: 'Milliliter', typicalMinQuantity: 500, estimatedPriceCents: 249, brand: 'K-PLANT BASED', store: 'Kaufland' },
{ name: 'K-PLANT BASED Drink Meal Kakao', category: 'Fertiger Getränkedrink', defaultPackageSize: 500, defaultUnit: 'Milliliter', typicalMinQuantity: 500, estimatedPriceCents: 199, brand: 'K-PLANT BASED', store: 'Kaufland' },
// ── Lidl Vemondo ────────────────────────────────────────────────────────────
{ name: 'Vemondo Bio Haferdrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 95, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Barista Haferdrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 125, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Bio Sojadrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 95, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Mandeldrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 125, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Kokos-Reisdrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 125, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Sojajoghurt Natur', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Sojajoghurt Vanille', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Sojajoghurt Frucht', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Kokosdessert', category: 'Dessert', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 59, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo No Ghurt', category: 'Joghurtalternative', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 129, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo No Butter', category: 'Butteralternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 199, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Vegane Scheiben mild/würzig', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Veganer Reibegenuss', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Veganer Streichgenuss', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Vegane Feta-Alternative', category: 'Käsealternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 149, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Vegane Kochcreme', category: 'Sahnealternative', defaultPackageSize: 200, defaultUnit: 'Milliliter', typicalMinQuantity: 200, estimatedPriceCents: 89, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Vegane Hackalternative', category: 'Hackalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 149, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Vegane Chunks', category: 'Fleischalternative', defaultPackageSize: 185, defaultUnit: 'Gramm', typicalMinQuantity: 185, estimatedPriceCents: 229, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Veganes Gyros', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 199, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Vegane Bratwurst', category: 'Wurstalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 199, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Burger Patties', category: 'Burgeralternative', defaultPackageSize: 227, defaultUnit: 'Gramm', typicalMinQuantity: 227, estimatedPriceCents: 199, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Mini-Schnitzel', category: 'Fleischalternative', defaultPackageSize: 300, defaultUnit: 'Gramm', typicalMinQuantity: 300, estimatedPriceCents: 219, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Vegane Nuggets', category: 'Fleischalternative', defaultPackageSize: 300, defaultUnit: 'Gramm', typicalMinQuantity: 300, estimatedPriceCents: 199, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Vegane Fischstäbchen', category: 'Fischalternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 219, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Vegane Falafel', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 179, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Snack-Frikadellen', category: 'Fleischalternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 179, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Tofu Natur', category: 'Tofu', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 179, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Räuchertofu', category: 'Tofu', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 149, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Veganes Pesto', category: 'Pesto', defaultPackageSize: 190, defaultUnit: 'Gramm', typicalMinQuantity: 190, estimatedPriceCents: 149, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Pizza Margherita', category: 'Pizza', defaultPackageSize: 350, defaultUnit: 'Gramm', typicalMinQuantity: 350, estimatedPriceCents: 249, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Pizza Salami', category: 'Pizza', defaultPackageSize: 350, defaultUnit: 'Gramm', typicalMinQuantity: 350, estimatedPriceCents: 249, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Nuss-Nougat-Creme', category: 'Süßer Aufstrich', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 199, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Karamell-Kekse', category: 'Kekse', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 149, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Vegane Muffins', category: 'Backwaren', defaultPackageSize: 1, defaultUnit: 'Stück', typicalMinQuantity: 1, estimatedPriceCents: 199, brand: 'Vemondo', store: 'Lidl' },
{ name: 'Vemondo Veganes Eis', category: 'Eis', defaultPackageSize: 500, defaultUnit: 'Milliliter', typicalMinQuantity: 500, estimatedPriceCents: 249, brand: 'Vemondo', store: 'Lidl' },
// ── ALDI MYVAY ─────────────────────────────────────────────────────────────
{ name: 'MYVAY Bio Haferdrink Natur', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 90, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Bio Haferdrink Mandel', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 90, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Bio Haferdrink ungesüßt', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 90, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Bio Mandeldrink ungesüßt', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 135, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Sojaghurt Natur', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Sojaghurt Vanille', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Sojaghurt Mango', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Veganes Hack gegart', category: 'Hackalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 159, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Wonder Chunks Chicken', category: 'Fleischalternative', defaultPackageSize: 185, defaultUnit: 'Gramm', typicalMinQuantity: 185, estimatedPriceCents: 245, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Wonder Chunks Döner', category: 'Fleischalternative', defaultPackageSize: 185, defaultUnit: 'Gramm', typicalMinQuantity: 185, estimatedPriceCents: 245, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Vegane Falafel', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 179, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Vegane Gemüsebällchen', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 179, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Vegane Nuggets/Schnitzel', category: 'Fleischalternative', defaultPackageSize: 300, defaultUnit: 'Gramm', typicalMinQuantity: 300, estimatedPriceCents: 219, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Vegane Grillwürste', category: 'Wurstalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 249, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY American Burger', category: 'Burgeralternative', defaultPackageSize: 227, defaultUnit: 'Gramm', typicalMinQuantity: 227, estimatedPriceCents: 225, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Crispy Chicken Burger', category: 'Burgeralternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 225, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Bio Tofu', category: 'Tofu', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 229, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Veganer Aufschnitt', category: 'Aufschnitt', defaultPackageSize: 100, defaultUnit: 'Gramm', typicalMinQuantity: 100, estimatedPriceCents: 95, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Vegane Salami', category: 'Aufschnitt', defaultPackageSize: 80, defaultUnit: 'Gramm', typicalMinQuantity: 80, estimatedPriceCents: 129, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Streichgenuss Natur', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Streichgenuss Kräuter', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Veganer Reibegenuss', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Vegane Scheiben mild', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Vegane Scheiben würzig', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Der Lockere Aufstrich', category: 'Aufstrich', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 129, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Feinkost-Mix Gurke/Kräuter', category: 'Feinkostsalat', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Feinkost-Mix Ei-Schnittlauch', category: 'Feinkostsalat', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
{ name: 'MYVAY Bio Streichcreme', category: 'Aufstrich', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 125, brand: 'MYVAY', store: 'ALDI' },
// ── Bekannte Markenprodukte ─────────────────────────────────────────────────
{ name: 'Alpro Haferdrink Original', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 189, brand: 'Alpro' },
{ name: 'Alpro Barista Hafer', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 229, brand: 'Alpro' },
{ name: 'Alpro Sojajoghurt Natur', category: 'Joghurtalternative', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 219, brand: 'Alpro' },
{ name: 'Oatly Barista Edition', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 229, brand: 'Oatly' },
{ name: 'Oatly Oatgurt', category: 'Joghurtalternative', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 199, brand: 'Oatly' },
{ name: 'vly Erbsendrink Barista', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 229, brand: 'vly' },
{ name: 'Rügenwalder Veganes Mühlen Mett', category: 'Aufstrich', defaultPackageSize: 100, defaultUnit: 'Gramm', typicalMinQuantity: 100, estimatedPriceCents: 249, brand: 'Rügenwalder Mühle' },
{ name: 'Rügenwalder Veganes Mühlen Hack', category: 'Hackalternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 249, brand: 'Rügenwalder Mühle' },
{ name: 'Rügenwalder Veganes Schnitzel', category: 'Fleischalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 279, brand: 'Rügenwalder Mühle' },
{ name: 'Rügenwalder Vegane Mini-Frikadellen', category: 'Fleischalternative', defaultPackageSize: 165, defaultUnit: 'Gramm', typicalMinQuantity: 165, estimatedPriceCents: 279, brand: 'Rügenwalder Mühle' },
{ name: 'Billie Green Vegane Salami', category: 'Aufschnitt', defaultPackageSize: 80, defaultUnit: 'Gramm', typicalMinQuantity: 80, estimatedPriceCents: 179, brand: 'Billie Green' },
{ name: 'Billie Green Veganer Bacon', category: 'Aufschnitt', defaultPackageSize: 90, defaultUnit: 'Gramm', typicalMinQuantity: 90, estimatedPriceCents: 199, brand: 'Billie Green' },
{ name: 'Billie Green Vegane Schinkenwürfel', category: 'Aufschnitt', defaultPackageSize: 90, defaultUnit: 'Gramm', typicalMinQuantity: 90, estimatedPriceCents: 199, brand: 'Billie Green' },
{ name: 'Billie Green Vegane Bratwurst', category: 'Wurstalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 329, brand: 'Billie Green' },
{ name: 'LikeMeat Like Chicken', category: 'Fleischalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 299, brand: 'LikeMeat' },
{ name: 'LikeMeat Like Gyros', category: 'Fleischalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 299, brand: 'LikeMeat' },
{ name: 'The Vegetarian Butcher Veganes Hack', category: 'Hackalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 349, brand: 'The Vegetarian Butcher' },
{ name: 'The Vegetarian Butcher Chickimicki', category: 'Fleischalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 349, brand: 'The Vegetarian Butcher' },
{ name: 'Beyond Meat Beyond Burger', category: 'Burgeralternative', defaultPackageSize: 226, defaultUnit: 'Gramm', typicalMinQuantity: 226, estimatedPriceCents: 429, brand: 'Beyond Meat' },
{ name: 'Beyond Meat Beyond Hack', category: 'Hackalternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 399, brand: 'Beyond Meat' },
{ name: 'Beyond Meat Beyond Sausage', category: 'Wurstalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 399, brand: 'Beyond Meat' },
{ name: 'planted. Chicken Chunks', category: 'Fleischalternative', defaultPackageSize: 160, defaultUnit: 'Gramm', typicalMinQuantity: 160, estimatedPriceCents: 349, brand: 'planted.' },
{ name: 'Garden Gourmet Sensational Burger', category: 'Burgeralternative', defaultPackageSize: 226, defaultUnit: 'Gramm', typicalMinQuantity: 226, estimatedPriceCents: 349, brand: 'Garden Gourmet' },
{ name: 'Simply V Genießerscheiben', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 229, brand: 'Simply V' },
{ name: 'Simply V Reibegenuss', category: 'Käsealternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 279, brand: 'Simply V' },
{ name: 'Violife Scheiben Mild', category: 'Käsealternative', defaultPackageSize: 140, defaultUnit: 'Gramm', typicalMinQuantity: 140, estimatedPriceCents: 249, brand: 'Violife' },
{ name: 'Violife Greek White Block', category: 'Käsealternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 249, brand: 'Violife' },
{ name: 'Bedda Vegane Scheiben', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 249, brand: 'Bedda' },
{ name: 'Dr. Oetker Ristorante Vegana', category: 'Pizza', defaultPackageSize: 340, defaultUnit: 'Gramm', typicalMinQuantity: 340, estimatedPriceCents: 349, brand: 'Dr. Oetker' },
{ name: 'Ben & Jerrys Non-Dairy Eis', category: 'Eis', defaultPackageSize: 465, defaultUnit: 'Milliliter', typicalMinQuantity: 465, estimatedPriceCents: 599, brand: 'Ben & Jerrys' },
{ name: 'Magnum Vegan Stieleis', category: 'Eis', defaultPackageSize: 3, defaultUnit: 'Stück', typicalMinQuantity: 3, estimatedPriceCents: 399, brand: 'Magnum' },
{ name: 'Bionella Bio Vegane Nuss-Nougat-Creme', category: 'Süßer Aufstrich', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 449, brand: 'Bionella' },
// ── Grundnahrungsmittel & Vorrats-Basics ──────────────────────────────────────
{ name: 'Spaghetti', category: 'Grundnahrungsmittel', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 189 },
{ name: 'Penne Nudeln', category: 'Grundnahrungsmittel', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 189 },
{ name: 'Basmati Reis', category: 'Grundnahrungsmittel', defaultPackageSize: 1000, defaultUnit: 'Gramm', typicalMinQuantity: 1000, estimatedPriceCents: 349 },
{ name: 'Zarte Haferflocken', category: 'Grundnahrungsmittel', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99 },
{ name: 'Rote Linsen', category: 'Grundnahrungsmittel', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 169 },
{ name: 'Weizenmehl Type 405', category: 'Grundnahrungsmittel', defaultPackageSize: 1000, defaultUnit: 'Gramm', typicalMinQuantity: 1000, estimatedPriceCents: 129 },
{ name: 'Hefeflocken', category: 'Gewürze', defaultPackageSize: 100, defaultUnit: 'Gramm', typicalMinQuantity: 100, estimatedPriceCents: 299 },
{ name: 'Paprikapulver edelsüß', category: 'Gewürze', defaultPackageSize: 1, defaultUnit: 'Dose', typicalMinQuantity: 1, estimatedPriceCents: 199 },
{ name: 'Strauchtomaten', category: 'Obst und Gemüse', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 229 },
{ name: 'Speisekartoffeln', category: 'Obst und Gemüse', defaultPackageSize: 1, defaultUnit: 'Kilogramm', typicalMinQuantity: 1, estimatedPriceCents: 199 },
{ name: 'Speisezwiebeln', category: 'Obst und Gemüse', defaultPackageSize: 1000, defaultUnit: 'Gramm', typicalMinQuantity: 1000, estimatedPriceCents: 149 },
{ name: 'Bio-Zitronen', category: 'Obst und Gemüse', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 179 },
];
+73
View File
@@ -0,0 +1,73 @@
/**
* Formats an integer amount in cents to German Euro representation.
* Example: 249 -> "2,49 €"
*/
export function formatCurrency(cents: number | null | undefined): string {
if (cents === null || cents === undefined || isNaN(cents)) {
return '0,00 €';
}
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(cents / 100);
}
/**
* Calculates stock value in cents.
* For integer/package units: quantity * packagePriceCents
* For partial packages: (quantity / packageSize) * packagePriceCents
*/
export function calculateStockValueCents(
quantity: number,
packageSize: number,
packagePriceCents: number | null | undefined
): number {
if (!packagePriceCents || packagePriceCents <= 0 || quantity <= 0) {
return 0;
}
if (packageSize <= 0) {
return Math.round(quantity * packagePriceCents);
}
const fraction = quantity / packageSize;
return Math.round(fraction * packagePriceCents);
}
/**
* Calculates base price (Grundpreis) per 1kg, 1l, 100g, or 1 Stück.
*/
export function calculateBasePrice(
priceCents: number,
packageSize: number,
packageUnit: string
): { basePriceCents: number; basePriceUnit: string } {
if (packageSize <= 0) {
return { basePriceCents: priceCents, basePriceUnit: packageUnit };
}
const unitLower = packageUnit.toLowerCase().trim();
if (unitLower === 'g' || unitLower === 'gramm') {
// Standard base price per 1 kg (1000 g)
const basePriceCents = Math.round((priceCents / packageSize) * 1000);
return { basePriceCents, basePriceUnit: '1 kg' };
}
if (unitLower === 'ml' || unitLower === 'milliliter') {
// Standard base price per 1 l (1000 ml)
const basePriceCents = Math.round((priceCents / packageSize) * 1000);
return { basePriceCents, basePriceUnit: '1 l' };
}
if (unitLower === 'kg' || unitLower === 'kilogramm') {
const basePriceCents = Math.round(priceCents / packageSize);
return { basePriceCents, basePriceUnit: '1 kg' };
}
if (unitLower === 'l' || unitLower === 'liter') {
const basePriceCents = Math.round(priceCents / packageSize);
return { basePriceCents, basePriceUnit: '1 l' };
}
const basePriceCents = Math.round(priceCents / packageSize);
return { basePriceCents, basePriceUnit: `1 ${packageUnit}` };
}
+53
View File
@@ -0,0 +1,53 @@
import { format, parseISO, differenceInCalendarDays, isValid } from 'date-fns';
import { de } from 'date-fns/locale';
import type { ExpirationWarningStatus } from '@/types/domain';
export function formatDateGerman(dateStr: string | Date | null | undefined): string {
if (!dateStr) return '-';
const date = typeof dateStr === 'string' ? parseISO(dateStr) : dateStr;
if (!isValid(date)) return '-';
return format(date, 'dd.MM.yyyy', { locale: de });
}
export function formatDateTimeGerman(dateStr: string | Date | null | undefined): string {
if (!dateStr) return '-';
const date = typeof dateStr === 'string' ? parseISO(dateStr) : dateStr;
if (!isValid(date)) return '-';
return format(date, 'dd.MM.yyyy HH:mm', { locale: de });
}
export function getExpirationStatus(expirationDateStr: string | null | undefined): ExpirationWarningStatus {
if (!expirationDateStr) {
return { status: 'OK', label: 'Kein Ablaufdatum', daysRemaining: null };
}
const expDate = parseISO(expirationDateStr);
if (!isValid(expDate)) {
return { status: 'OK', label: 'Kein Ablaufdatum', daysRemaining: null };
}
const today = new Date();
const daysDiff = differenceInCalendarDays(expDate, today);
if (daysDiff < 0) {
return {
status: 'EXPIRED',
label: `Abgelaufen (${Math.abs(daysDiff)} Tag${Math.abs(daysDiff) === 1 ? '' : 'e'})`,
daysRemaining: daysDiff,
};
}
if (daysDiff === 0) {
return { status: 'TODAY', label: 'Läuft heute ab', daysRemaining: 0 };
}
if (daysDiff <= 3) {
return { status: 'IN_3_DAYS', label: `Läuft in ${daysDiff} Tagen ab`, daysRemaining: daysDiff };
}
if (daysDiff <= 7) {
return { status: 'IN_7_DAYS', label: `Läuft in ${daysDiff} Tagen ab`, daysRemaining: daysDiff };
}
if (daysDiff <= 14) {
return { status: 'IN_14_DAYS', label: `Läuft in ${daysDiff} Tagen ab`, daysRemaining: daysDiff };
}
return { status: 'OK', label: `Haltbar bis ${formatDateGerman(expDate)}`, daysRemaining: daysDiff };
}
+43
View File
@@ -0,0 +1,43 @@
export const GRAM_SLIDER_STEPS = [50, 100, 250, 500, 1000];
export function isGramUnit(packageUnit: string): boolean {
const u = (packageUnit || '').toLowerCase().trim();
return u === 'gramm' || u === 'g';
}
export function getProductStepSize(
packageUnit: string,
categoryName?: string | null,
productName?: string | null
): { step: number; label: string } {
const unit = (packageUnit || '').toLowerCase().trim();
const name = (productName || '').toLowerCase().trim();
const cat = (categoryName || '').toLowerCase().trim();
if (unit === 'gramm' || unit === 'g') {
const isSpice =
cat.includes('gewürz') ||
/paprika|hefe|gewürz|salz|pfeffer|curry|zimt|oregano|basilikum|thymian|knoblauch|chili|kümmel|rosmarin|muskat|kurkuma/i.test(
name
);
if (isSpice) {
return { step: 50, label: '50g' };
}
return { step: 250, label: '250g' };
}
if (unit === 'kilogramm' || unit === 'kg') {
return { step: 1, label: '1kg' };
}
if (unit === 'liter' || unit === 'l') {
return { step: 1, label: '1l' };
}
if (unit === 'milliliter' || unit === 'ml') {
return { step: 250, label: '250ml' };
}
return { step: 1, label: '1' };
}
+18
View File
@@ -0,0 +1,18 @@
import 'server-only';
import { createClient } from '@supabase/supabase-js';
export function createAdminClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceRoleKey) {
throw new Error('Fehlende Supabase Admin Umgebungsvariablen (NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY)');
}
return createClient(supabaseUrl, serviceRoleKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}
+12
View File
@@ -0,0 +1,12 @@
import { createBrowserClient as createBrowserSupabaseClient } from '@supabase/ssr';
export function createBrowserClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Fehlende Supabase Umgebungsvariablen (NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY)');
}
return createBrowserSupabaseClient(supabaseUrl, supabaseAnonKey);
}
+85
View File
@@ -0,0 +1,85 @@
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function updateSession(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
});
// Check for local Amy/Mandy session cookie fallback
const localUserCookie = request.cookies.get('kitchenstock_user')?.value;
let user: any = null;
if (localUserCookie) {
try {
user = JSON.parse(localUserCookie);
} catch {
user = null;
}
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!user && supabaseUrl && supabaseAnonKey && !supabaseUrl.includes('your-supabase-project')) {
try {
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet: Array<{ name: string; value: string; options?: any }>) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
response = NextResponse.next({
request,
});
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
});
const { data } = await supabase.auth.getUser();
user = data.user;
} catch {
user = null;
}
}
const isAuthRoute =
request.nextUrl.pathname.startsWith('/login') ||
request.nextUrl.pathname.startsWith('/register') ||
request.nextUrl.pathname.startsWith('/forgot-password') ||
request.nextUrl.pathname.startsWith('/reset-password');
const isDashboardRoute =
request.nextUrl.pathname.startsWith('/dashboard') ||
request.nextUrl.pathname.startsWith('/inventory') ||
request.nextUrl.pathname.startsWith('/shopping') ||
request.nextUrl.pathname.startsWith('/inventory-check') ||
request.nextUrl.pathname.startsWith('/recipes') ||
request.nextUrl.pathname.startsWith('/meal-plans') ||
request.nextUrl.pathname.startsWith('/ai-assistant') ||
request.nextUrl.pathname.startsWith('/statistics') ||
request.nextUrl.pathname.startsWith('/settings');
if (!user && isDashboardRoute) {
const url = request.nextUrl.clone();
url.pathname = '/login';
url.searchParams.set('redirectTo', request.nextUrl.pathname);
return NextResponse.redirect(url);
}
if (user && isAuthRoute) {
const url = request.nextUrl.clone();
url.pathname = '/dashboard';
return NextResponse.redirect(url);
}
return response;
}
+193
View File
@@ -0,0 +1,193 @@
import { createServerClient as createSSRServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
// 100% Vegan default mock products with realistic units (500g, 1l, 200g)
const MOCK_PRODUCTS = [
{
id: 'p-1',
household_id: 'demo-household-id',
name: 'Hafermilch Barista Bio',
brand: 'Oatly',
package_size: 1,
package_unit: 'Liter',
minimum_quantity: 2,
favorite: true,
estimated_price_cents: 149,
categories: { name: 'Pflanzliche Milch' },
locations: { name: 'Kühlschrank' },
inventory_batches: [{ id: 'b-1', quantity: 3, expiration_date: '2026-10-15', purchase_price_cents: 149 }],
},
{
id: 'p-2',
household_id: 'demo-household-id',
name: 'Spaghetti',
brand: 'Barilla',
package_size: 500,
package_unit: 'Gramm',
minimum_quantity: 1,
favorite: true,
estimated_price_cents: 189,
categories: { name: 'Grundnahrungsmittel' },
locations: { name: 'Vorratsschrank' },
inventory_batches: [{ id: 'b-2', quantity: 2, expiration_date: '2027-01-20', purchase_price_cents: 189 }],
},
{
id: 'p-3',
household_id: 'demo-household-id',
name: 'Tofu Natur Bio',
brand: 'Taifun',
package_size: 200,
package_unit: 'Gramm',
minimum_quantity: 1,
favorite: true,
estimated_price_cents: 199,
categories: { name: 'Kühlwaren' },
locations: { name: 'Kühlschrank' },
inventory_batches: [{ id: 'b-3', quantity: 2, expiration_date: '2026-08-10', purchase_price_cents: 199 }],
},
{
id: 'p-4',
household_id: 'demo-household-id',
name: 'Hefeflocken',
brand: 'Alnatura',
package_size: 200,
package_unit: 'Gramm',
minimum_quantity: 1,
favorite: true,
estimated_price_cents: 299,
categories: { name: 'Gewürze' },
locations: { name: 'Gewürzschrank' },
inventory_batches: [{ id: 'b-4', quantity: 1, expiration_date: '2027-05-01', purchase_price_cents: 299 }],
},
{
id: 'p-5',
household_id: 'demo-household-id',
name: 'Strauchtomaten',
brand: 'Bio',
package_size: 500,
package_unit: 'Gramm',
minimum_quantity: 1,
favorite: false,
estimated_price_cents: 229,
categories: { name: 'Obst und Gemüse' },
locations: { name: 'Kühlschrank' },
inventory_batches: [{ id: 'b-5', quantity: 1, expiration_date: '2026-07-28', purchase_price_cents: 229 }],
},
];
function createMockQueryBuilder(tableName: string) {
const builder: any = {
select: () => builder,
eq: () => builder,
neq: () => builder,
gt: () => builder,
gte: () => builder,
lt: () => builder,
lte: () => builder,
order: () => builder,
limit: () => builder,
insert: () => builder,
update: () => builder,
delete: () => builder,
upsert: () => builder,
single: async () => {
if (tableName === 'household_members' || tableName === 'households') {
return {
data: {
household_id: 'demo-household-id',
user_id: 'demo-user-id',
role: 'OWNER',
households: { id: 'demo-household-id', name: 'Amy & Mandys Küche' },
id: 'demo-household-id',
name: 'Amy & Mandys Küche',
},
error: null,
};
}
return { data: null, error: null };
},
then: (resolve: (res: any) => void) => {
if (tableName === 'products') {
resolve({ data: MOCK_PRODUCTS, error: null });
} else if (tableName === 'shopping_lists') {
resolve({
data: [
{
id: 'sl-1',
name: 'Einkaufsliste',
status: 'ACTIVE',
shopping_list_items: [
{ id: 'sli-1', quantity: 2, estimated_unit_price_cents: 149, checked: false },
],
},
],
error: null,
});
} else {
resolve({ data: [], error: null });
}
},
};
return builder;
}
export async function createServerClient() {
const cookieStore = await cookies();
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const localUserCookie = cookieStore.get('kitchenstock_user')?.value;
let localUser: any = null;
if (localUserCookie) {
try {
localUser = JSON.parse(localUserCookie);
} catch {
localUser = null;
}
}
if (!supabaseUrl || !supabaseAnonKey || supabaseUrl.includes('your-supabase-project') || localUser) {
return {
auth: {
getUser: async () => ({
data: {
user: {
id: localUser?.id || 'demo-user-id',
email: localUser?.email || 'amy@kitchenstock.local',
user_metadata: { display_name: localUser?.displayName || 'Amy' },
},
},
error: null,
}),
},
from: (table: string) => createMockQueryBuilder(table),
rpc: async (fn: string) => {
if (fn === 'adjust_product_stock') return { data: 5, error: null };
if (fn === 'check_rate_limit') return { data: true, error: null };
return { data: null, error: null };
},
} as any;
}
return createSSRServerClient(
supabaseUrl,
supabaseAnonKey,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet: Array<{ name: string; value: string; options?: any }>) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Ignored in read-only context
}
},
},
}
);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+61
View File
@@ -0,0 +1,61 @@
export interface RealismCheckResult {
isUnrealistic: boolean;
warning?: string;
suggestedQuantity?: number;
suggestedUnit?: string;
}
export function validateRealism(
productName: string,
quantity: number,
unit: string
): RealismCheckResult {
if (!productName || quantity <= 0) {
return { isUnrealistic: false };
}
const nameLower = productName.toLowerCase();
const unitLower = unit.toLowerCase();
const isBulkFood =
nameLower.includes('nudel') ||
nameLower.includes('spaghetti') ||
nameLower.includes('penne') ||
nameLower.includes('reis') ||
nameLower.includes('mehl') ||
nameLower.includes('haferflock') ||
nameLower.includes('linse') ||
nameLower.includes('zucker');
// Check 1: Unrealistically tiny weight for bulk foods (e.g. 4g Nudeln)
if (isBulkFood && (unitLower === 'g' || unitLower === 'gramm') && quantity < 100) {
return {
isUnrealistic: true,
warning: `Unrealistische Menge: ${quantity}g ${productName} ist zu wenig (Packungen wiegen meist 500g).`,
suggestedQuantity: 500,
suggestedUnit: 'Gramm',
};
}
// Check 2: Massive weight confusion (e.g. 500 kg Nudeln instead of 500g)
if (isBulkFood && (unitLower === 'kg' || unitLower === 'kilogramm') && quantity >= 50) {
return {
isUnrealistic: true,
warning: `Meinst du ${quantity} Gramm anstelle von ${quantity} kg ${productName}?`,
suggestedQuantity: quantity,
suggestedUnit: 'Gramm',
};
}
// Check 3: Liquid in grams or solid in liters
if ((nameLower.includes('milch') || nameLower.includes('saft') || nameLower.includes('wasser')) && unitLower === 'gramm') {
return {
isUnrealistic: true,
warning: `Flüssigkeiten werden meist in Litern (l) oder ml angegeben.`,
suggestedQuantity: 1,
suggestedUnit: 'Liter',
};
}
return { isUnrealistic: false };
}
+75
View File
@@ -0,0 +1,75 @@
export interface VeganCheckResult {
isVegan: boolean;
warning?: string;
nonVeganIngredientFound?: string;
}
const NON_VEGAN_KEYWORDS = [
'kuhmilch',
'milch',
'hackfleisch',
'fleisch',
'rindfleisch',
'schweinefleisch',
'hähnchen',
'geflügel',
'wurst',
'salami',
'schinken',
'käse',
'gouda',
'mozzarella',
'parmesan',
'butter',
'sahne',
'eier',
'eigelb',
'eiwolle',
'honig',
'fisch',
'lachs',
'thunfisch',
'gelatine',
];
const VEGAN_QUALIFIERS = [
'vegan',
'veganer',
'veganes',
'pflanzlich',
'pflanzliche',
'hafer',
'soja',
'mandel',
'kokos',
'erbsen',
'reis',
'ohne ei',
];
export function checkIsVegan(name: string): VeganCheckResult {
if (!name || name.trim().length === 0) {
return { isVegan: true };
}
const cleanName = name.toLowerCase();
// Check if explicit vegan qualifiers are present (e.g. "Vegane Sahne", "Hafermilch")
const hasVeganQualifier = VEGAN_QUALIFIERS.some(qualifier => cleanName.includes(qualifier));
if (hasVeganQualifier) {
return { isVegan: true };
}
// Check for non-vegan keywords
for (const keyword of NON_VEGAN_KEYWORDS) {
if (cleanName.includes(keyword)) {
return {
isVegan: false,
warning: `⚠️ NICHT VEGAN: "${name}" enthält voraussichtlich tierische Bestandteile!`,
nonVeganIngredientFound: keyword,
};
}
}
return { isVegan: true };
}
+12
View File
@@ -0,0 +1,12 @@
import { type NextRequest } from 'next/server';
import { updateSession } from '@/lib/supabase/middleware';
export async function middleware(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
+53
View File
@@ -0,0 +1,53 @@
export interface ApiErrorResponse {
error: {
code: string;
message: string;
requestId: string;
details?: unknown;
};
}
export interface ApiResponse<T> {
data?: T;
error?: {
code: string;
message: string;
requestId: string;
};
}
export interface AdjustStockInput {
householdId: string;
productId: string;
batchId?: string;
quantityChange: number;
reason: 'PURCHASE' | 'CONSUMED' | 'CORRECTION' | 'INVENTORY' | 'EXPIRED' | 'DISCARDED' | 'TRANSFER' | 'INITIAL';
note?: string;
}
export interface CompleteShoppingInput {
householdId: string;
shoppingListId: string;
store: string;
purchaseDate?: string;
items: Array<{
itemId: string;
productId?: string;
customName?: string;
actualQuantity: number;
actualUnitPriceCents: number;
}>;
}
export interface PriceSearchApiInput {
householdId: string;
query: string;
brand?: string;
retailer?: string;
}
export interface PriceUpdateBatchInput {
householdId: string;
productIds?: string[];
maxProducts?: number;
}
+317
View File
@@ -0,0 +1,317 @@
export type HouseholdRole = 'OWNER' | 'ADMIN' | 'MEMBER' | 'VIEWER';
export type AldiRegionType = 'NORD' | 'SUED' | 'UNSPECIFIED';
export type UnitType = 'COUNT' | 'WEIGHT' | 'VOLUME' | 'OTHER';
export type StockMovementReason = 'PURCHASE' | 'CONSUMED' | 'CORRECTION' | 'INVENTORY' | 'EXPIRED' | 'DISCARDED' | 'TRANSFER' | 'INITIAL';
export type ShoppingListStatus = 'ACTIVE' | 'COMPLETED' | 'ARCHIVED';
export type PriceType = 'REGULAR' | 'OFFER' | 'LOYALTY' | 'UNKNOWN';
export type SourceKind = 'USER_PURCHASE' | 'USER_ESTIMATE' | 'OFFICIAL_RETAILER' | 'OFFICIAL_PROSPECT' | 'GEMINI_GROUNDED' | 'HISTORICAL_AVERAGE' | 'AI_ESTIMATE';
export type ConfidenceLevel = 'HIGH' | 'MEDIUM' | 'LOW';
export type BudgetPeriod = 'WEEKLY' | 'MONTHLY' | 'CUSTOM';
export type InventorySessionStatus = 'OPEN' | 'COMPLETED' | 'CANCELLED';
export type DietType = 'OMNIVORE' | 'VEGETARIAN' | 'VEGAN' | 'PESCETARIAN' | 'CUSTOM';
export type PendingActionStatus = 'PENDING' | 'CONFIRMED' | 'REJECTED' | 'EXPIRED';
export interface ProfileRow {
id: string;
display_name: string;
avatar_path: string | null;
locale: string;
timezone: string;
created_at: string;
updated_at: string;
}
export interface HouseholdRow {
id: string;
name: string;
currency: string;
locale: string;
timezone: string;
postal_code: string | null;
city: string | null;
aldi_region: AldiRegionType;
default_servings: number;
owner_id: string;
created_at: string;
updated_at: string;
}
export interface HouseholdMemberRow {
household_id: string;
user_id: string;
role: HouseholdRole;
created_at: string;
}
export interface CategoryRow {
id: string;
household_id: string;
name: string;
icon: string | null;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface LocationRow {
id: string;
household_id: string;
parent_location_id: string | null;
name: string;
icon: string | null;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface UnitRow {
id: string;
household_id: string | null;
name: string;
abbreviation: string;
unit_type: UnitType;
allows_decimals: boolean;
created_at: string;
}
export interface ProductRow {
id: string;
household_id: string;
category_id: string | null;
default_location_id: string | null;
default_unit_id: string | null;
name: string;
brand: string | null;
description: string | null;
barcode: string | null;
image_path: string | null;
package_size: number;
package_unit: string;
minimum_quantity: number;
favorite: boolean;
preferred_store: string | null;
estimated_price_cents: number | null;
price_updated_at: string | null;
archived: boolean;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface InventoryBatchRow {
id: string;
household_id: string;
product_id: string;
location_id: string | null;
quantity: number;
expiration_date: string | null;
purchase_date: string | null;
purchase_price_cents: number | null;
store: string | null;
lot_number: string | null;
created_at: string;
updated_at: string;
}
export interface StockMovementRow {
id: string;
household_id: string;
product_id: string;
batch_id: string | null;
quantity_before: number;
quantity_change: number;
quantity_after: number;
reason: StockMovementReason;
note: string | null;
created_by: string | null;
created_at: string;
}
export interface ShoppingListRow {
id: string;
household_id: string;
name: string;
status: ShoppingListStatus;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface ShoppingListItemRow {
id: string;
shopping_list_id: string;
product_id: string | null;
custom_name: string | null;
quantity: number;
unit_id: string | null;
estimated_unit_price_cents: number | null;
selected_store: string | null;
checked: boolean;
automatically_added: boolean;
notes: string | null;
created_at: string;
updated_at: string;
}
export interface PurchaseRow {
id: string;
household_id: string;
store: string;
purchase_date: string;
total_price_cents: number;
receipt_image_path: string | null;
notes: string | null;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface PurchaseItemRow {
id: string;
purchase_id: string;
product_id: string | null;
quantity: number;
unit_price_cents: number;
total_price_cents: number;
package_size: number | null;
package_unit: string | null;
created_at: string;
}
export interface PriceObservationRow {
id: string;
household_id: string;
product_id: string | null;
product_name: string;
brand: string | null;
package_size: number | null;
package_unit: string | null;
price_cents: number;
base_price_cents: number | null;
base_price_unit: string | null;
retailer: string;
price_type: PriceType;
postal_code: string | null;
region: string | null;
source_url: string | null;
source_title: string | null;
observed_at: string;
valid_from: string | null;
valid_until: string | null;
confidence: ConfidenceLevel;
exact_match: boolean;
verified: boolean;
source_kind: SourceKind;
created_by: string | null;
created_at: string;
}
export interface BudgetRow {
id: string;
household_id: string;
name: string;
period: BudgetPeriod;
amount_cents: number;
warning_threshold_percent: number;
start_date: string;
end_date: string | null;
created_at: string;
updated_at: string;
}
export interface InventorySessionRow {
id: string;
household_id: string;
location_id: string | null;
status: InventorySessionStatus;
started_by: string | null;
started_at: string;
completed_at: string | null;
created_at: string;
}
export interface InventorySessionItemRow {
id: string;
session_id: string;
product_id: string;
expected_quantity: number;
counted_quantity: number;
difference: number;
confirmed: boolean;
notes: string | null;
created_at: string;
updated_at: string;
}
export interface DietaryPreferenceRow {
id: string;
household_id: string;
diet_type: DietType;
excluded_ingredients: string[];
allergens: string[];
preferred_cuisines: string[];
maximum_preparation_minutes: number | null;
maximum_meal_budget_cents: number | null;
default_servings: number;
updated_at: string;
}
export interface RecipeRow {
id: string;
household_id: string;
title: string;
description: string | null;
servings: number;
preparation_minutes: number | null;
difficulty: string | null;
estimated_cost_cents: number | null;
inventory_coverage_percent: number;
ingredients_json: unknown;
steps_json: unknown;
dietary_tags: string[];
allergen_warnings: string[];
source_information_json: unknown;
created_by_ai: boolean;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface MealPlanRow {
id: string;
household_id: string;
name: string;
start_date: string;
end_date: string;
estimated_total_cost_cents: number | null;
plan_json: unknown;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface AiSettingRow {
id: string;
household_id: string;
enabled: boolean;
preferred_retailers: string[];
allow_google_search: boolean;
daily_request_limit: number;
automatic_price_updates: boolean;
price_update_interval_days: number;
maximum_products_per_price_request: number;
save_chat_history: boolean;
updated_at: string;
}
export interface AiPendingActionRow {
id: string;
household_id: string;
user_id: string;
action_type: string;
payload_json: unknown;
status: PendingActionStatus;
expires_at: string;
created_at: string;
confirmed_at: string | null;
}
+2
View File
@@ -0,0 +1,2 @@
declare module 'lucide-react';
declare module 'recharts';
+183
View File
@@ -0,0 +1,183 @@
import type {
HouseholdRole,
AldiRegionType,
UnitType,
StockMovementReason,
ShoppingListStatus,
PriceType,
SourceKind,
ConfidenceLevel,
BudgetPeriod,
InventorySessionStatus,
DietType,
PendingActionStatus,
} from './database';
export interface ProductWithStock {
id: string;
householdId: string;
categoryId: string | null;
categoryName?: string | null;
defaultLocationId: string | null;
defaultLocationName?: string | null;
defaultUnitId: string | null;
defaultUnitName?: string | null;
name: string;
brand: string | null;
description: string | null;
barcode: string | null;
imagePath: string | null;
packageSize: number;
packageUnit: string;
minimumQuantity: number;
favorite: boolean;
preferredStore: string | null;
estimatedPriceCents: number | null;
priceUpdatedAt: string | null;
archived: boolean;
totalQuantity: number;
stockValueCents: number;
isLowStock: boolean;
isExpiringSoon: boolean;
earliestExpirationDate: string | null;
batches?: InventoryBatchDomain[];
}
export interface InventoryBatchDomain {
id: string;
householdId: string;
productId: string;
locationId: string | null;
locationName?: string | null;
quantity: number;
expirationDate: string | null;
purchaseDate: string | null;
purchasePriceCents: number | null;
store: string | null;
lotNumber: string | null;
createdAt: string;
updatedAt: string;
}
export interface StockMovementDomain {
id: string;
householdId: string;
productId: string;
productName?: string;
batchId: string | null;
quantityBefore: number;
quantityChange: number;
quantityAfter: number;
reason: StockMovementReason;
note: string | null;
createdBy: string | null;
createdByName?: string | null;
createdAt: string;
}
export interface ShoppingListItemDomain {
id: string;
shoppingListId: string;
productId: string | null;
productName?: string | null;
customName: string | null;
displayName: string;
quantity: number;
unitId: string | null;
unitName?: string | null;
estimatedUnitPriceCents: number | null;
totalEstimatedPriceCents: number;
selectedStore: string | null;
checked: boolean;
automaticallyAdded: boolean;
notes: string | null;
createdAt: string;
}
export interface PurchaseDomain {
id: string;
householdId: string;
store: string;
purchaseDate: string;
totalPriceCents: number;
receiptImagePath: string | null;
notes: string | null;
createdBy: string | null;
items?: PurchaseItemDomain[];
createdAt: string;
}
export interface PurchaseItemDomain {
id: string;
purchaseId: string;
productId: string | null;
productName?: string | null;
quantity: number;
unitPriceCents: number;
totalPriceCents: number;
packageSize: number | null;
packageUnit: string | null;
}
export interface PriceObservationDomain {
id: string;
householdId: string;
productId: string | null;
productName: string;
brand: string | null;
packageSize: number | null;
packageUnit: string | null;
priceCents: number;
basePriceCents: number | null;
basePriceUnit: string | null;
retailer: string;
priceType: PriceType;
postalCode: string | null;
region: string | null;
sourceUrl: string | null;
sourceTitle: string | null;
observedAt: string;
validFrom: string | null;
validUntil: string | null;
confidence: ConfidenceLevel;
exactMatch: boolean;
verified: boolean;
sourceKind: SourceKind;
createdBy: string | null;
}
export interface ExpirationWarningStatus {
status: 'EXPIRED' | 'TODAY' | 'IN_3_DAYS' | 'IN_7_DAYS' | 'IN_14_DAYS' | 'OK';
label: string;
daysRemaining: number | null;
}
export interface RecipeIngredientDomain {
name: string;
requiredQuantity: number;
unit: string;
availableQuantity: number;
status: 'AVAILABLE' | 'PARTIAL' | 'MISSING' | 'OPTIONAL';
estimatedPriceCents?: number;
productId?: string;
}
export interface RecipeDomain {
id: string;
title: string;
description: string;
servings: number;
preparationMinutes: number;
difficulty: 'EINFACH' | 'MITTEL' | 'SCHWER';
estimatedAdditionalCostCents: number;
inventoryCoveragePercent: number;
availableIngredients: RecipeIngredientDomain[];
partialIngredients: RecipeIngredientDomain[];
missingIngredients: RecipeIngredientDomain[];
optionalIngredients: RecipeIngredientDomain[];
steps: string[];
expiringProductsUsed: string[];
dietaryTags: string[];
allergenWarnings: string[];
storageAdvice?: string;
}
@@ -0,0 +1,412 @@
-- Migration 00001: Initial Schema for KitchenStock
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Enums
CREATE TYPE household_role AS ENUM ('OWNER', 'ADMIN', 'MEMBER', 'VIEWER');
CREATE TYPE aldi_region_type AS ENUM ('NORD', 'SUED', 'UNSPECIFIED');
CREATE TYPE unit_type AS ENUM ('COUNT', 'WEIGHT', 'VOLUME', 'OTHER');
CREATE TYPE stock_movement_reason AS ENUM ('PURCHASE', 'CONSUMED', 'CORRECTION', 'INVENTORY', 'EXPIRED', 'DISCARDED', 'TRANSFER', 'INITIAL');
CREATE TYPE shopping_list_status AS ENUM ('ACTIVE', 'COMPLETED', 'ARCHIVED');
CREATE TYPE price_type AS ENUM ('REGULAR', 'OFFER', 'LOYALTY', 'UNKNOWN');
CREATE TYPE source_kind AS ENUM ('USER_PURCHASE', 'USER_ESTIMATE', 'OFFICIAL_RETAILER', 'OFFICIAL_PROSPECT', 'GEMINI_GROUNDED', 'HISTORICAL_AVERAGE', 'AI_ESTIMATE');
CREATE TYPE confidence_level AS ENUM ('HIGH', 'MEDIUM', 'LOW');
CREATE TYPE budget_period AS ENUM ('WEEKLY', 'MONTHLY', 'CUSTOM');
CREATE TYPE inventory_session_status AS ENUM ('OPEN', 'COMPLETED', 'CANCELLED');
CREATE TYPE diet_type AS ENUM ('OMNIVORE', 'VEGETARIAN', 'VEGAN', 'PESCETARIAN', 'CUSTOM');
CREATE TYPE pending_action_status AS ENUM ('PENDING', 'CONFIRMED', 'REJECTED', 'EXPIRED');
-- Trigger to update updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 1. Profiles
CREATE TABLE IF NOT EXISTS public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
display_name TEXT NOT NULL,
avatar_path TEXT,
locale TEXT NOT NULL DEFAULT 'de-DE',
timezone TEXT NOT NULL DEFAULT 'Europe/Berlin',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 2. Households
CREATE TABLE IF NOT EXISTS public.households (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
currency TEXT NOT NULL DEFAULT 'EUR',
locale TEXT NOT NULL DEFAULT 'de-DE',
timezone TEXT NOT NULL DEFAULT 'Europe/Berlin',
postal_code TEXT,
city TEXT,
aldi_region aldi_region_type NOT NULL DEFAULT 'UNSPECIFIED',
default_servings INT NOT NULL DEFAULT 2 CHECK (default_servings > 0),
owner_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 3. Household Members
CREATE TABLE IF NOT EXISTS public.household_members (
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
role household_role NOT NULL DEFAULT 'MEMBER',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (household_id, user_id)
);
-- 4. Categories
CREATE TABLE IF NOT EXISTS public.categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
name TEXT NOT NULL,
icon TEXT,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 5. Locations (supports nesting)
CREATE TABLE IF NOT EXISTS public.locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
parent_location_id UUID REFERENCES public.locations(id) ON DELETE SET NULL,
name TEXT NOT NULL,
icon TEXT,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 6. Units
CREATE TABLE IF NOT EXISTS public.units (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID REFERENCES public.households(id) ON DELETE CASCADE, -- NULL for system defaults
name TEXT NOT NULL,
abbreviation TEXT NOT NULL,
unit_type unit_type NOT NULL DEFAULT 'COUNT',
allows_decimals BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 7. Products
CREATE TABLE IF NOT EXISTS public.products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
category_id UUID REFERENCES public.categories(id) ON DELETE SET NULL,
default_location_id UUID REFERENCES public.locations(id) ON DELETE SET NULL,
default_unit_id UUID REFERENCES public.units(id) ON DELETE SET NULL,
name TEXT NOT NULL,
brand TEXT,
description TEXT,
barcode TEXT,
image_path TEXT,
package_size NUMERIC(10, 3) NOT NULL DEFAULT 1.0,
package_unit TEXT NOT NULL DEFAULT 'Stück',
minimum_quantity NUMERIC(10, 3) NOT NULL DEFAULT 0.0 CHECK (minimum_quantity >= 0),
favorite BOOLEAN NOT NULL DEFAULT false,
preferred_store TEXT,
estimated_price_cents INT CHECK (estimated_price_cents >= 0),
price_updated_at TIMESTAMPTZ,
archived BOOLEAN NOT NULL DEFAULT false,
created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 8. Inventory Batches
CREATE TABLE IF NOT EXISTS public.inventory_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
location_id UUID REFERENCES public.locations(id) ON DELETE SET NULL,
quantity NUMERIC(10, 3) NOT NULL DEFAULT 0.0 CHECK (quantity >= 0),
expiration_date DATE,
purchase_date DATE,
purchase_price_cents INT CHECK (purchase_price_cents >= 0),
store TEXT,
lot_number TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 9. Stock Movements
CREATE TABLE IF NOT EXISTS public.stock_movements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
batch_id UUID REFERENCES public.inventory_batches(id) ON DELETE SET NULL,
quantity_before NUMERIC(10, 3) NOT NULL,
quantity_change NUMERIC(10, 3) NOT NULL,
quantity_after NUMERIC(10, 3) NOT NULL CHECK (quantity_after >= 0),
reason stock_movement_reason NOT NULL,
note TEXT,
created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 10. Shopping Lists
CREATE TABLE IF NOT EXISTS public.shopping_lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Einkaufsliste',
status shopping_list_status NOT NULL DEFAULT 'ACTIVE',
created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 11. Shopping List Items
CREATE TABLE IF NOT EXISTS public.shopping_list_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
shopping_list_id UUID NOT NULL REFERENCES public.shopping_lists(id) ON DELETE CASCADE,
product_id UUID REFERENCES public.products(id) ON DELETE SET NULL,
custom_name TEXT,
quantity NUMERIC(10, 3) NOT NULL DEFAULT 1.0 CHECK (quantity > 0),
unit_id UUID REFERENCES public.units(id) ON DELETE SET NULL,
estimated_unit_price_cents INT CHECK (estimated_unit_price_cents >= 0),
selected_store TEXT,
checked BOOLEAN NOT NULL DEFAULT false,
automatically_added BOOLEAN NOT NULL DEFAULT false,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 12. Purchases
CREATE TABLE IF NOT EXISTS public.purchases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
store TEXT NOT NULL,
purchase_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
total_price_cents INT NOT NULL DEFAULT 0 CHECK (total_price_cents >= 0),
receipt_image_path TEXT,
notes TEXT,
created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 13. Purchase Items
CREATE TABLE IF NOT EXISTS public.purchase_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
purchase_id UUID NOT NULL REFERENCES public.purchases(id) ON DELETE CASCADE,
product_id UUID REFERENCES public.products(id) ON DELETE SET NULL,
quantity NUMERIC(10, 3) NOT NULL DEFAULT 1.0 CHECK (quantity > 0),
unit_price_cents INT NOT NULL CHECK (unit_price_cents >= 0),
total_price_cents INT NOT NULL CHECK (total_price_cents >= 0),
package_size NUMERIC(10, 3),
package_unit TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 14. Price Observations
CREATE TABLE IF NOT EXISTS public.price_observations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
product_id UUID REFERENCES public.products(id) ON DELETE SET NULL,
product_name TEXT NOT NULL,
brand TEXT,
package_size NUMERIC(10, 3),
package_unit TEXT,
price_cents INT NOT NULL CHECK (price_cents >= 0),
base_price_cents INT CHECK (base_price_cents >= 0),
base_price_unit TEXT,
retailer TEXT NOT NULL,
price_type price_type NOT NULL DEFAULT 'REGULAR',
postal_code TEXT,
region TEXT,
source_url TEXT,
source_title TEXT,
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
valid_from DATE,
valid_until DATE,
confidence confidence_level NOT NULL DEFAULT 'MEDIUM',
exact_match BOOLEAN NOT NULL DEFAULT true,
verified BOOLEAN NOT NULL DEFAULT false,
source_kind source_kind NOT NULL DEFAULT 'USER_ESTIMATE',
created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 15. Budgets
CREATE TABLE IF NOT EXISTS public.budgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
name TEXT NOT NULL,
period budget_period NOT NULL DEFAULT 'MONTHLY',
amount_cents INT NOT NULL CHECK (amount_cents >= 0),
warning_threshold_percent INT NOT NULL DEFAULT 80 CHECK (warning_threshold_percent BETWEEN 1 AND 100),
start_date DATE NOT NULL,
end_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 16. Inventory Sessions
CREATE TABLE IF NOT EXISTS public.inventory_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
location_id UUID REFERENCES public.locations(id) ON DELETE SET NULL,
status inventory_session_status NOT NULL DEFAULT 'OPEN',
started_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 17. Inventory Session Items
CREATE TABLE IF NOT EXISTS public.inventory_session_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES public.inventory_sessions(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
expected_quantity NUMERIC(10, 3) NOT NULL DEFAULT 0.0,
counted_quantity NUMERIC(10, 3) NOT NULL DEFAULT 0.0,
difference NUMERIC(10, 3) NOT NULL DEFAULT 0.0,
confirmed BOOLEAN NOT NULL DEFAULT false,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 18. Dietary Preferences
CREATE TABLE IF NOT EXISTS public.dietary_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL UNIQUE REFERENCES public.households(id) ON DELETE CASCADE,
diet_type diet_type NOT NULL DEFAULT 'OMNIVORE',
excluded_ingredients TEXT[] NOT NULL DEFAULT '{}',
allergens TEXT[] NOT NULL DEFAULT '{}',
preferred_cuisines TEXT[] NOT NULL DEFAULT '{}',
maximum_preparation_minutes INT,
maximum_meal_budget_cents INT,
default_servings INT NOT NULL DEFAULT 2,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 19. Recipes
CREATE TABLE IF NOT EXISTS public.recipes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
servings INT NOT NULL DEFAULT 2,
preparation_minutes INT,
difficulty TEXT,
estimated_cost_cents INT,
inventory_coverage_percent INT NOT NULL DEFAULT 0,
ingredients_json JSONB NOT NULL DEFAULT '[]'::jsonb,
steps_json JSONB NOT NULL DEFAULT '[]'::jsonb,
dietary_tags TEXT[] NOT NULL DEFAULT '{}',
allergen_warnings TEXT[] NOT NULL DEFAULT '{}',
source_information_json JSONB,
created_by_ai BOOLEAN NOT NULL DEFAULT false,
created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 20. Meal Plans
CREATE TABLE IF NOT EXISTS public.meal_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
name TEXT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
estimated_total_cost_cents INT,
plan_json JSONB NOT NULL DEFAULT '[]'::jsonb,
created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 21. AI Settings
CREATE TABLE IF NOT EXISTS public.ai_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL UNIQUE REFERENCES public.households(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT true,
preferred_retailers TEXT[] NOT NULL DEFAULT '{"KAUFLAND", "REWE", "ALDI_NORD", "ALDI_SUED", "LIDL"}',
allow_google_search BOOLEAN NOT NULL DEFAULT true,
daily_request_limit INT NOT NULL DEFAULT 30 CHECK (daily_request_limit > 0),
automatic_price_updates BOOLEAN NOT NULL DEFAULT false,
price_update_interval_days INT NOT NULL DEFAULT 14 CHECK (price_update_interval_days > 0),
maximum_products_per_price_request INT NOT NULL DEFAULT 20 CHECK (maximum_products_per_price_request > 0),
save_chat_history BOOLEAN NOT NULL DEFAULT true,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 22. AI Request Logs
CREATE TABLE IF NOT EXISTS public.ai_request_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
user_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
feature TEXT NOT NULL,
model TEXT NOT NULL,
grounding_used BOOLEAN NOT NULL DEFAULT false,
input_token_count INT,
output_token_count INT,
latency_ms INT,
successful BOOLEAN NOT NULL DEFAULT true,
error_code TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 23. AI Pending Actions
CREATE TABLE IF NOT EXISTS public.ai_pending_actions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
action_type TEXT NOT NULL,
payload_json JSONB NOT NULL,
status pending_action_status NOT NULL DEFAULT 'PENDING',
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '24 hours'),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
confirmed_at TIMESTAMPTZ
);
-- 24. Rate Limit Events
CREATE TABLE IF NOT EXISTS public.rate_limit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
action TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Triggers for updated_at
CREATE TRIGGER trg_profiles_updated_at BEFORE UPDATE ON public.profiles FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER trg_households_updated_at BEFORE UPDATE ON public.households FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER trg_categories_updated_at BEFORE UPDATE ON public.categories FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER trg_locations_updated_at BEFORE UPDATE ON public.locations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER trg_products_updated_at BEFORE UPDATE ON public.products FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER trg_inventory_batches_updated_at BEFORE UPDATE ON public.inventory_batches FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER trg_shopping_lists_updated_at BEFORE UPDATE ON public.shopping_lists FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER trg_shopping_list_items_updated_at BEFORE UPDATE ON public.shopping_list_items FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER trg_purchases_updated_at BEFORE UPDATE ON public.purchases FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER trg_budgets_updated_at BEFORE UPDATE ON public.budgets FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER trg_inventory_session_items_updated_at BEFORE UPDATE ON public.inventory_session_items FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER trg_dietary_preferences_updated_at BEFORE UPDATE ON public.dietary_preferences FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER trg_recipes_updated_at BEFORE UPDATE ON public.recipes FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER trg_meal_plans_updated_at BEFORE UPDATE ON public.meal_plans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER trg_ai_settings_updated_at BEFORE UPDATE ON public.ai_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Indexes for optimal query performance
CREATE INDEX idx_household_members_user ON public.household_members(user_id);
CREATE INDEX idx_products_household ON public.products(household_id);
CREATE INDEX idx_products_category ON public.products(category_id);
CREATE INDEX idx_products_location ON public.products(default_location_id);
CREATE INDEX idx_products_barcode ON public.products(barcode);
CREATE INDEX idx_inventory_batches_product ON public.inventory_batches(product_id);
CREATE INDEX idx_inventory_batches_household ON public.inventory_batches(household_id);
CREATE INDEX idx_inventory_batches_exp ON public.inventory_batches(expiration_date);
CREATE INDEX idx_stock_movements_product ON public.stock_movements(product_id);
CREATE INDEX idx_stock_movements_household ON public.stock_movements(household_id);
CREATE INDEX idx_shopping_list_items_list ON public.shopping_list_items(shopping_list_id);
CREATE INDEX idx_purchases_household_date ON public.purchases(household_id, purchase_date);
CREATE INDEX idx_price_observations_product ON public.price_observations(product_id);
CREATE INDEX idx_price_observations_observed ON public.price_observations(observed_at);
CREATE INDEX idx_rate_limit_events_user_action ON public.rate_limit_events(user_id, action, created_at);
+211
View File
@@ -0,0 +1,211 @@
-- Migration 00002: Row Level Security (RLS) Policies and Helper Functions
-- 1. Helper Functions
CREATE OR REPLACE FUNCTION public.is_household_member(_household_id UUID)
RETURNS BOOLEAN
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
STABLE
AS $$
SELECT EXISTS (
SELECT 1
FROM public.household_members
WHERE household_id = _household_id
AND user_id = auth.uid()
);
$$;
CREATE OR REPLACE FUNCTION public.has_household_role(_household_id UUID, _allowed_roles public.household_role[])
RETURNS BOOLEAN
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
STABLE
AS $$
SELECT EXISTS (
SELECT 1
FROM public.household_members
WHERE household_id = _household_id
AND user_id = auth.uid()
AND role = ANY(_allowed_roles)
);
$$;
-- Enable RLS on all tables
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.households ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.household_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.categories ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.locations ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.units ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.products ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.inventory_batches ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.stock_movements ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.shopping_lists ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.shopping_list_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.purchases ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.purchase_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.price_observations ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.budgets ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.inventory_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.inventory_session_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dietary_preferences ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.recipes ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.meal_plans ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ai_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ai_request_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ai_pending_actions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rate_limit_events ENABLE ROW LEVEL SECURITY;
-- 2. Policies for profiles
CREATE POLICY "Users can view all profiles in their households"
ON public.profiles FOR SELECT
USING (
id = auth.uid() OR
EXISTS (
SELECT 1 FROM public.household_members m1
JOIN public.household_members m2 ON m1.household_id = m2.household_id
WHERE m1.user_id = auth.uid() AND m2.user_id = public.profiles.id
)
);
CREATE POLICY "Users can update their own profile"
ON public.profiles FOR UPDATE
USING (id = auth.uid())
WITH CHECK (id = auth.uid());
CREATE POLICY "Users can insert their own profile"
ON public.profiles FOR INSERT
WITH CHECK (id = auth.uid());
-- 3. Policies for households
CREATE POLICY "Members can view their households"
ON public.households FOR SELECT
USING (public.is_household_member(id));
CREATE POLICY "Authenticated users can create households"
ON public.households FOR INSERT
WITH CHECK (owner_id = auth.uid());
CREATE POLICY "OWNER and ADMIN can update household"
ON public.households FOR UPDATE
USING (public.has_household_role(id, ARRAY['OWNER', 'ADMIN']::public.household_role[]));
CREATE POLICY "Only OWNER can delete household"
ON public.households FOR DELETE
USING (owner_id = auth.uid());
-- 4. Policies for household_members
CREATE POLICY "Members can view household members"
ON public.household_members FOR SELECT
USING (public.is_household_member(household_id));
CREATE POLICY "OWNER and ADMIN can add household members"
ON public.household_members FOR INSERT
WITH CHECK (public.has_household_role(household_id, ARRAY['OWNER', 'ADMIN']::public.household_role[]));
CREATE POLICY "OWNER and ADMIN can update member roles"
ON public.household_members FOR UPDATE
USING (public.has_household_role(household_id, ARRAY['OWNER', 'ADMIN']::public.household_role[]));
CREATE POLICY "OWNER and ADMIN can remove members"
ON public.household_members FOR DELETE
USING (public.has_household_role(household_id, ARRAY['OWNER', 'ADMIN']::public.household_role[]) OR user_id = auth.uid());
-- Helper Macro macro-like pattern for tables scoped by household_id
-- Categories, Locations, Units, Products, Inventory Batches, Stock Movements, Shopping Lists, Purchases, Price Observations, Budgets, Inventory Sessions, Dietary Preferences, Recipes, Meal Plans, AI Settings, AI Pending Actions
-- Categories
CREATE POLICY "Members can view categories" ON public.categories FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "MEMBER, ADMIN, OWNER can insert categories" ON public.categories FOR INSERT WITH CHECK (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
CREATE POLICY "MEMBER, ADMIN, OWNER can update categories" ON public.categories FOR UPDATE USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
CREATE POLICY "ADMIN and OWNER can delete categories" ON public.categories FOR DELETE USING (public.has_household_role(household_id, ARRAY['ADMIN', 'OWNER']::public.household_role[]));
-- Locations
CREATE POLICY "Members can view locations" ON public.locations FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "MEMBER, ADMIN, OWNER can insert locations" ON public.locations FOR INSERT WITH CHECK (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
CREATE POLICY "MEMBER, ADMIN, OWNER can update locations" ON public.locations FOR UPDATE USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
CREATE POLICY "ADMIN and OWNER can delete locations" ON public.locations FOR DELETE USING (public.has_household_role(household_id, ARRAY['ADMIN', 'OWNER']::public.household_role[]));
-- Units
CREATE POLICY "Members can view units" ON public.units FOR SELECT USING (household_id IS NULL OR public.is_household_member(household_id));
CREATE POLICY "MEMBER, ADMIN, OWNER can insert units" ON public.units FOR INSERT WITH CHECK (household_id IS NOT NULL AND public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
CREATE POLICY "MEMBER, ADMIN, OWNER can update units" ON public.units FOR UPDATE USING (household_id IS NOT NULL AND public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
-- Products
CREATE POLICY "Members can view products" ON public.products FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "MEMBER, ADMIN, OWNER can insert products" ON public.products FOR INSERT WITH CHECK (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
CREATE POLICY "MEMBER, ADMIN, OWNER can update products" ON public.products FOR UPDATE USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
CREATE POLICY "ADMIN and OWNER can delete products" ON public.products FOR DELETE USING (public.has_household_role(household_id, ARRAY['ADMIN', 'OWNER']::public.household_role[]));
-- Inventory Batches
CREATE POLICY "Members can view inventory batches" ON public.inventory_batches FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "MEMBER, ADMIN, OWNER can modify inventory batches" ON public.inventory_batches FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
-- Stock Movements
CREATE POLICY "Members can view stock movements" ON public.stock_movements FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "MEMBER, ADMIN, OWNER can insert stock movements" ON public.stock_movements FOR INSERT WITH CHECK (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
-- Shopping Lists
CREATE POLICY "Members can view shopping lists" ON public.shopping_lists FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "MEMBER, ADMIN, OWNER can modify shopping lists" ON public.shopping_lists FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
-- Shopping List Items
CREATE POLICY "Members can view shopping list items" ON public.shopping_list_items FOR SELECT USING (
EXISTS (SELECT 1 FROM public.shopping_lists WHERE id = shopping_list_id AND public.is_household_member(household_id))
);
CREATE POLICY "MEMBER, ADMIN, OWNER can modify shopping list items" ON public.shopping_list_items FOR ALL USING (
EXISTS (SELECT 1 FROM public.shopping_lists WHERE id = shopping_list_id AND public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]))
);
-- Purchases & Purchase Items
CREATE POLICY "Members can view purchases" ON public.purchases FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "MEMBER, ADMIN, OWNER can modify purchases" ON public.purchases FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
CREATE POLICY "Members can view purchase items" ON public.purchase_items FOR SELECT USING (
EXISTS (SELECT 1 FROM public.purchases WHERE id = purchase_id AND public.is_household_member(household_id))
);
CREATE POLICY "MEMBER, ADMIN, OWNER can modify purchase items" ON public.purchase_items FOR ALL USING (
EXISTS (SELECT 1 FROM public.purchases WHERE id = purchase_id AND public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]))
);
-- Price Observations
CREATE POLICY "Members can view price observations" ON public.price_observations FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "MEMBER, ADMIN, OWNER can modify price observations" ON public.price_observations FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
-- Budgets
CREATE POLICY "Members can view budgets" ON public.budgets FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "ADMIN and OWNER can modify budgets" ON public.budgets FOR ALL USING (public.has_household_role(household_id, ARRAY['ADMIN', 'OWNER']::public.household_role[]));
-- Inventory Sessions & Session Items
CREATE POLICY "Members can view inventory sessions" ON public.inventory_sessions FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "MEMBER, ADMIN, OWNER can modify inventory sessions" ON public.inventory_sessions FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
CREATE POLICY "Members can view inventory session items" ON public.inventory_session_items FOR SELECT USING (
EXISTS (SELECT 1 FROM public.inventory_sessions WHERE id = session_id AND public.is_household_member(household_id))
);
CREATE POLICY "MEMBER, ADMIN, OWNER can modify inventory session items" ON public.inventory_session_items FOR ALL USING (
EXISTS (SELECT 1 FROM public.inventory_sessions WHERE id = session_id AND public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]))
);
-- Dietary Preferences, Recipes, Meal Plans, AI Settings, AI Pending Actions
CREATE POLICY "Members can view dietary preferences" ON public.dietary_preferences FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "MEMBER, ADMIN, OWNER can update dietary preferences" ON public.dietary_preferences FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
CREATE POLICY "Members can view recipes" ON public.recipes FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "MEMBER, ADMIN, OWNER can modify recipes" ON public.recipes FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
CREATE POLICY "Members can view meal plans" ON public.meal_plans FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "MEMBER, ADMIN, OWNER can modify meal plans" ON public.meal_plans FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
CREATE POLICY "Members can view AI settings" ON public.ai_settings FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "ADMIN and OWNER can modify AI settings" ON public.ai_settings FOR ALL USING (public.has_household_role(household_id, ARRAY['ADMIN', 'OWNER']::public.household_role[]));
CREATE POLICY "Members can view AI logs" ON public.ai_request_logs FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "System can insert AI logs" ON public.ai_request_logs FOR INSERT WITH CHECK (public.is_household_member(household_id));
CREATE POLICY "Members can view pending actions" ON public.ai_pending_actions FOR SELECT USING (public.is_household_member(household_id));
CREATE POLICY "MEMBER, ADMIN, OWNER can modify pending actions" ON public.ai_pending_actions FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
CREATE POLICY "Users can manage their rate limit events" ON public.rate_limit_events FOR ALL USING (user_id = auth.uid());
+148
View File
@@ -0,0 +1,148 @@
-- Migration 00003: Transactional Stock Adjustment RPC (adjust_product_stock with FEFO)
CREATE OR REPLACE FUNCTION public.adjust_product_stock(
p_household_id UUID,
p_product_id UUID,
p_quantity_change NUMERIC,
p_reason public.stock_movement_reason,
p_batch_id UUID DEFAULT NULL,
p_note TEXT DEFAULT NULL
)
RETURNS NUMERIC
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id UUID;
v_total_before NUMERIC := 0.0;
v_total_after NUMERIC := 0.0;
v_target_batch RECORD;
v_remaining_to_deduct NUMERIC;
v_deduct_amount NUMERIC;
BEGIN
v_user_id := auth.uid();
-- 1. Check permissions
IF NOT public.has_household_role(p_household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]) THEN
RAISE EXCEPTION 'PERMISSION_DENIED: User is not authorized to modify stock in household %', p_household_id;
END IF;
-- 2. Lock product and calculate current total stock
PERFORM 1 FROM public.products WHERE id = p_product_id AND household_id = p_household_id FOR UPDATE;
SELECT COALESCE(SUM(quantity), 0.0) INTO v_total_before
FROM public.inventory_batches
WHERE household_id = p_household_id AND product_id = p_product_id;
-- 3. Check for negative stock
IF (v_total_before + p_quantity_change) < 0 THEN
RAISE EXCEPTION 'INSUFFICIENT_STOCK: Required quantity reduction exceeds current total stock (%)', v_total_before;
END IF;
-- 4. Apply changes
IF p_batch_id IS NOT NULL THEN
-- Target specific batch
SELECT * INTO v_target_batch
FROM public.inventory_batches
WHERE id = p_batch_id AND product_id = p_product_id AND household_id = p_household_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'BATCH_NOT_FOUND: Specified batch % does not exist', p_batch_id;
END IF;
IF (v_target_batch.quantity + p_quantity_change) < 0 THEN
RAISE EXCEPTION 'INSUFFICIENT_BATCH_STOCK: Batch quantity cannot be reduced below 0';
END IF;
UPDATE public.inventory_batches
SET quantity = quantity + p_quantity_change,
updated_at = NOW()
WHERE id = p_batch_id;
ELSE
-- No explicit batch provided
IF p_quantity_change > 0 THEN
-- Increase stock: Add to default batch (batch without expiration_date or newest) or create new batch
SELECT id INTO p_batch_id
FROM public.inventory_batches
WHERE household_id = p_household_id
AND product_id = p_product_id
AND expiration_date IS NULL
ORDER BY created_at DESC
LIMIT 1;
IF p_batch_id IS NOT NULL THEN
UPDATE public.inventory_batches
SET quantity = quantity + p_quantity_change,
updated_at = NOW()
WHERE id = p_batch_id;
ELSE
INSERT INTO public.inventory_batches (household_id, product_id, quantity)
VALUES (p_household_id, p_product_id, p_quantity_change)
RETURNING id INTO p_batch_id;
END IF;
ELSIF p_quantity_change < 0 THEN
-- FEFO Deduction (First Expire, First Out)
v_remaining_to_deduct := ABS(p_quantity_change);
FOR v_target_batch IN
SELECT id, quantity
FROM public.inventory_batches
WHERE household_id = p_household_id
AND product_id = p_product_id
AND quantity > 0
ORDER BY
expiration_date ASC NULLS LAST,
created_at ASC
FOR UPDATE
LOOP
IF v_remaining_to_deduct <= 0 THEN
EXIT;
END IF;
v_deduct_amount := LEAST(v_target_batch.quantity, v_remaining_to_deduct);
UPDATE public.inventory_batches
SET quantity = quantity - v_deduct_amount,
updated_at = NOW()
WHERE id = v_target_batch.id;
v_remaining_to_deduct := v_remaining_to_deduct - v_deduct_amount;
END LOOP;
END IF;
END IF;
-- 5. Calculate new total stock
SELECT COALESCE(SUM(quantity), 0.0) INTO v_total_after
FROM public.inventory_batches
WHERE household_id = p_household_id AND product_id = p_product_id;
-- 6. Insert stock_movements record
INSERT INTO public.stock_movements (
household_id,
product_id,
batch_id,
quantity_before,
quantity_change,
quantity_after,
reason,
note,
created_by
) VALUES (
p_household_id,
p_product_id,
p_batch_id,
v_total_before,
p_quantity_change,
v_total_after,
p_reason,
p_note,
v_user_id
);
RETURN v_total_after;
END;
$$;
@@ -0,0 +1,48 @@
-- Migration 00004: Rate Limiting Function
CREATE OR REPLACE FUNCTION public.check_rate_limit(
p_action TEXT,
p_max_requests INT,
p_window_seconds INT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id UUID;
v_count INT;
v_window_start TIMESTAMPTZ;
BEGIN
v_user_id := auth.uid();
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'UNAUTHORIZED: User must be authenticated to check rate limit';
END IF;
v_window_start := NOW() - (p_window_seconds || ' seconds')::INTERVAL;
-- Clean up old events for this user/action
DELETE FROM public.rate_limit_events
WHERE user_id = v_user_id
AND action = p_action
AND created_at < v_window_start;
-- Count requests in current window
SELECT COUNT(*) INTO v_count
FROM public.rate_limit_events
WHERE user_id = v_user_id
AND action = p_action
AND created_at >= v_window_start;
IF v_count >= p_max_requests THEN
RETURN FALSE;
END IF;
-- Record request
INSERT INTO public.rate_limit_events (user_id, action)
VALUES (v_user_id, p_action);
RETURN TRUE;
END;
$$;
+133
View File
@@ -0,0 +1,133 @@
-- Migration 00005: Seed Demo Data Function
CREATE OR REPLACE FUNCTION public.seed_household_demo_data(p_household_id UUID)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id UUID;
v_cat_gewuerze UUID;
v_cat_kuehl UUID;
v_cat_tiefkuehl UUID;
v_cat_getraenke UUID;
v_cat_konserven UUID;
v_cat_back UUID;
v_cat_grund UUID;
v_cat_obst_gemuese UUID;
v_cat_haushalt UUID;
v_loc_kuehlschrank UUID;
v_loc_gefrierschrank UUID;
v_loc_gewuerzschrank UUID;
v_loc_vorratsschrank UUID;
v_loc_getraenkelager UUID;
v_unit_stueck UUID;
v_unit_packung UUID;
v_unit_flasche UUID;
v_unit_dose UUID;
v_unit_g UUID;
v_unit_kg UUID;
v_unit_l UUID;
BEGIN
v_user_id := auth.uid();
IF NOT public.has_household_role(p_household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]) THEN
RAISE EXCEPTION 'PERMISSION_DENIED: User is not authorized to seed household %', p_household_id;
END IF;
-- Categories
INSERT INTO public.categories (household_id, name, sort_order) VALUES
(p_household_id, 'Gewürze', 1) RETURNING id INTO v_cat_gewuerze;
INSERT INTO public.categories (household_id, name, sort_order) VALUES
(p_household_id, 'Kühlwaren', 2) RETURNING id INTO v_cat_kuehl;
INSERT INTO public.categories (household_id, name, sort_order) VALUES
(p_household_id, 'Tiefkühlprodukte', 3) RETURNING id INTO v_cat_tiefkuehl;
INSERT INTO public.categories (household_id, name, sort_order) VALUES
(p_household_id, 'Getränke', 4) RETURNING id INTO v_cat_getraenke;
INSERT INTO public.categories (household_id, name, sort_order) VALUES
(p_household_id, 'Konserven', 5) RETURNING id INTO v_cat_konserven;
INSERT INTO public.categories (household_id, name, sort_order) VALUES
(p_household_id, 'Backzutaten', 6) RETURNING id INTO v_cat_back;
INSERT INTO public.categories (household_id, name, sort_order) VALUES
(p_household_id, 'Grundnahrungsmittel', 7) RETURNING id INTO v_cat_grund;
INSERT INTO public.categories (household_id, name, sort_order) VALUES
(p_household_id, 'Obst und Gemüse', 8) RETURNING id INTO v_cat_obst_gemuese;
INSERT INTO public.categories (household_id, name, sort_order) VALUES
(p_household_id, 'Haushalt', 9) RETURNING id INTO v_cat_haushalt;
-- Locations
INSERT INTO public.locations (household_id, name, sort_order) VALUES
(p_household_id, 'Kühlschrank', 1) RETURNING id INTO v_loc_kuehlschrank;
INSERT INTO public.locations (household_id, name, sort_order) VALUES
(p_household_id, 'Gefrierschrank', 2) RETURNING id INTO v_loc_gefrierschrank;
INSERT INTO public.locations (household_id, name, sort_order) VALUES
(p_household_id, 'Gewürzschrank', 3) RETURNING id INTO v_loc_gewuerzschrank;
INSERT INTO public.locations (household_id, name, sort_order) VALUES
(p_household_id, 'Vorratsschrank', 4) RETURNING id INTO v_loc_vorratsschrank;
INSERT INTO public.locations (household_id, name, sort_order) VALUES
(p_household_id, 'Getränkelager', 5) RETURNING id INTO v_loc_getraenkelager;
-- Units
INSERT INTO public.units (household_id, name, abbreviation, unit_type, allows_decimals) VALUES
(p_household_id, 'Stück', 'Stk.', 'COUNT', false) RETURNING id INTO v_unit_stueck;
INSERT INTO public.units (household_id, name, abbreviation, unit_type, allows_decimals) VALUES
(p_household_id, 'Packung', 'Pk.', 'COUNT', false) RETURNING id INTO v_unit_packung;
INSERT INTO public.units (household_id, name, abbreviation, unit_type, allows_decimals) VALUES
(p_household_id, 'Flasche', 'Fl.', 'COUNT', false) RETURNING id INTO v_unit_flasche;
INSERT INTO public.units (household_id, name, abbreviation, unit_type, allows_decimals) VALUES
(p_household_id, 'Dose', 'Ds.', 'COUNT', false) RETURNING id INTO v_unit_dose;
INSERT INTO public.units (household_id, name, abbreviation, unit_type, allows_decimals) VALUES
(p_household_id, 'Gramm', 'g', 'WEIGHT', true) RETURNING id INTO v_unit_g;
INSERT INTO public.units (household_id, name, abbreviation, unit_type, allows_decimals) VALUES
(p_household_id, 'Kilogramm', 'kg', 'WEIGHT', true) RETURNING id INTO v_unit_kg;
INSERT INTO public.units (household_id, name, abbreviation, unit_type, allows_decimals) VALUES
(p_household_id, 'Liter', 'l', 'VOLUME', true) RETURNING id INTO v_unit_l;
-- Standard Products with Batches
-- Paprikapulver
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
VALUES (p_household_id, v_cat_gewuerze, v_loc_gewuerzschrank, v_unit_dose, 'Paprikapulver', 'Ostmann', 1, 'Dose', 1, 199, true, v_user_id);
-- Salz
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
VALUES (p_household_id, v_cat_gewuerze, v_loc_gewuerzschrank, v_unit_packung, 'Salz', 'Bad Reichenhaller', 500, 'g', 1, 99, false, v_user_id);
-- Pfeffer
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
VALUES (p_household_id, v_cat_gewuerze, v_loc_gewuerzschrank, v_unit_dose, 'Pfeffer schwarz', 'Ostmann', 1, 'Dose', 1, 249, false, v_user_id);
-- Mehl
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
VALUES (p_household_id, v_cat_back, v_loc_vorratsschrank, v_unit_kg, 'Weizenmehl Type 405', 'Diamant', 1000, 'g', 2, 129, false, v_user_id);
-- Nudeln
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
VALUES (p_household_id, v_cat_grund, v_loc_vorratsschrank, v_unit_packung, 'Spaghetti', 'Barilla', 500, 'g', 2, 189, true, v_user_id);
-- Reis
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
VALUES (p_household_id, v_cat_grund, v_loc_vorratsschrank, v_unit_packung, 'Basmati Reis', 'Uncle Ben''s', 1000, 'g', 1, 349, false, v_user_id);
-- Milch
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
VALUES (p_household_id, v_cat_kuehl, v_loc_kuehlschrank, v_unit_l, 'Vollmilch 3.5%', 'Weihenstephan', 1, 'l', 2, 119, true, v_user_id);
-- Cola Zero
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
VALUES (p_household_id, v_cat_getraenke, v_loc_getraenkelager, v_unit_flasche, 'Coca-Cola Zero', 'Coca-Cola', 1.5, 'l', 4, 129, false, v_user_id);
-- Tomaten
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
VALUES (p_household_id, v_cat_obst_gemuese, v_loc_kuehlschrank, v_unit_packung, 'Strauchtomaten', 'Bio', 500, 'g', 1, 229, true, v_user_id);
-- Kartoffeln
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
VALUES (p_household_id, v_cat_obst_gemuese, v_loc_vorratsschrank, v_unit_kg, 'Speisekartoffeln festkochend', 'Regional', 2.5, 'kg', 1, 299, false, v_user_id);
-- Zwiebeln
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
VALUES (p_household_id, v_cat_obst_gemuese, v_loc_vorratsschrank, v_unit_packung, 'Speisezwiebeln', 'Regional', 1, 'kg', 1, 149, true, v_user_id);
END;
$$;
+2
View File
@@ -0,0 +1,2 @@
// Dummy mock for Next.js 'server-only' package in Vitest environment
export {};
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { MockKitchenAiProvider } from '@/lib/ai/client';
describe('MockKitchenAiProvider (No Billing Test)', () => {
const provider = new MockKitchenAiProvider();
it('suggests recipes with expected structured outputs', async () => {
const recipes = await provider.suggestRecipes({
householdId: 'test-household',
inventory: [
{
id: 'prod-1',
householdId: 'test-household',
categoryId: null,
defaultLocationId: null,
defaultUnitId: null,
name: 'Reis',
brand: null,
description: null,
barcode: null,
imagePath: null,
packageSize: 1000,
packageUnit: 'g',
minimumQuantity: 100,
favorite: true,
preferredStore: null,
estimatedPriceCents: 249,
priceUpdatedAt: null,
archived: false,
totalQuantity: 1000,
stockValueCents: 249,
isLowStock: false,
isExpiringSoon: false,
earliestExpirationDate: null,
},
],
});
expect(recipes).toHaveLength(1);
expect(recipes[0].title).toBeDefined();
expect(recipes[0].inventoryCoveragePercent).toBeGreaterThan(0);
});
it('returns verified price searches via Mock Provider', async () => {
const price = await provider.searchPrices({
householdId: 'test-household',
query: 'Spaghetti',
retailer: 'REWE',
});
expect(price.priceCents).toBe(189);
expect(price.confidence).toBe('HIGH');
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { formatCurrency, calculateBasePrice, calculateStockValueCents } from '@/lib/formatting/currency';
describe('Currency & Stock Value Formatting', () => {
it('formats integer cents into German Euro currency string', () => {
expect(formatCurrency(249)).toContain('2,49');
expect(formatCurrency(0)).toContain('0,00');
expect(formatCurrency(1299)).toContain('12,99');
});
it('calculates full package stock value correctly', () => {
// 2 packages @ 1,99 € (199 Cent) = 398 Cent
expect(calculateStockValueCents(2, 1, 199)).toBe(398);
});
it('calculates partial package stock value correctly (750g of 1000g @ 1,29 € = 0,97 €)', () => {
// 750g / 1000g * 129 Cent = 96.75 Cent -> rounded to 97 Cent
expect(calculateStockValueCents(750, 1000, 129)).toBe(97);
});
it('calculates base price (Grundpreis) per 1kg for grams', () => {
// 500g Barilla Spaghetti @ 1,89 € (189 Cent) -> 3.78 € / kg (378 Cent)
const base = calculateBasePrice(189, 500, 'g');
expect(base.basePriceCents).toBe(378);
expect(base.basePriceUnit).toBe('1 kg');
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { formatDateGerman, getExpirationStatus } from '@/lib/formatting/date';
describe('Date & Expiration Status Helpers', () => {
it('formats ISO dates into German TT.MM.JJJJ format', () => {
expect(formatDateGerman('2026-07-26')).toBe('26.07.2026');
});
it('returns OK for null expiration date', () => {
const status = getExpirationStatus(null);
expect(status.status).toBe('OK');
expect(status.label).toBe('Kein Ablaufdatum');
});
it('correctly flags expired dates', () => {
const status = getExpirationStatus('2020-01-01');
expect(status.status).toBe('EXPIRED');
expect(status.label).toContain('Abgelaufen');
});
});
+42
View File
@@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": [
"node_modules"
]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'server-only': path.resolve(__dirname, './tests/mocks/server-only.ts'),
},
},
});