feat: KitchenStock light UI, Gemma 31B AI, supermarket store filters & 130+ vegan products
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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 €).`;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 & Jerry’s Non-Dairy Eis', category: 'Eis', defaultPackageSize: 465, defaultUnit: 'Milliliter', typicalMinQuantity: 465, estimatedPriceCents: 599, brand: 'Ben & Jerry’s' },
|
||||
{ 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 },
|
||||
];
|
||||
@@ -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}` };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)$).*)',
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare module 'lucide-react';
|
||||
declare module 'recharts';
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user