feat: Smart Shopping List + Household bugfixes
Shopping: - Merged view (default): same ingredient + unit = summed amounts (150g Quark + 300g Quark → 450g Quark) - Shows which recipes need each ingredient - Toggle between 'Zusammengefasst' and 'Nach Rezept' views - Alphabetically sorted in merged view Household bugfixes: - Fixed bouncy modal animation (removed Framer Motion spring) - Fixed clipboard copy on HTTP (textarea fallback) - Fixed logout on join (JWT secrets mismatch in docker-compose) Also: mounted backend data/ volume for recipe images
This commit is contained in:
@@ -22,11 +22,13 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./backend/data:/app/data
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://luna:${DB_PASSWORD:-luna-recipes-secret-2026}@db:5432/luna_recipes
|
DATABASE_URL: postgresql://luna:${DB_PASSWORD:-luna-recipes-secret-2026}@db:5432/luna_recipes
|
||||||
JWT_SECRET: ${JWT_SECRET:-luna-jwt-change-in-prod}
|
JWT_SECRET: ${JWT_SECRET:-luna-recipes-jwt-secret-change-in-prod-2026}
|
||||||
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-luna-refresh-change-in-prod}
|
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-luna-recipes-refresh-secret-change-in-prod-2026}
|
||||||
COOKIE_SECRET: ${COOKIE_SECRET:-luna-cookie-change-in-prod}
|
COOKIE_SECRET: ${COOKIE_SECRET:-luna-recipes-cookie-secret-change-in-prod-2026}
|
||||||
PORT: "6001"
|
PORT: "6001"
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Image proxy to backend
|
||||||
|
location /images/ {
|
||||||
|
proxy_pass http://backend:6001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
# API proxy to backend
|
# API proxy to backend
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:6001;
|
proxy_pass http://backend:6001;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
|
||||||
import { Home, Users, Copy, RefreshCw, LogOut, X, Check } from 'lucide-react'
|
import { Home, Users, Copy, RefreshCw, LogOut, X, Check } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getMyHousehold,
|
getMyHousehold,
|
||||||
@@ -23,13 +22,20 @@ export function HouseholdCard() {
|
|||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState('')
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data: household, isLoading } = useQuery({
|
||||||
queryKey: ['household'],
|
queryKey: ['household'],
|
||||||
queryFn: getMyHousehold,
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const res = await getMyHousehold()
|
||||||
|
return res.data as Household
|
||||||
|
} catch {
|
||||||
|
// 404 = no household, that's fine
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const household: Household | null = data?.data ?? null
|
|
||||||
const myRole = household?.members.find((m) => m.user_id === user?.id)?.role
|
const myRole = household?.members.find((m) => m.user_id === user?.id)?.role
|
||||||
|
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
@@ -73,14 +79,29 @@ export function HouseholdCard() {
|
|||||||
onError: (err: Error) => showToast.error(err.message),
|
onError: (err: Error) => showToast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const copyCode = async () => {
|
// Clipboard fallback for HTTP (no navigator.clipboard)
|
||||||
|
const copyCode = () => {
|
||||||
if (!household?.invite_code) return
|
if (!household?.invite_code) return
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(household.invite_code)
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(household.invite_code)
|
||||||
|
} else {
|
||||||
|
// Fallback: textarea trick
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = household.invite_code
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.left = '-9999px'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
}
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
|
showToast.success('Code kopiert!')
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
} catch {
|
} catch {
|
||||||
showToast.error('Kopieren fehlgeschlagen')
|
// Last resort: show code in prompt
|
||||||
|
showToast.error('Kopiere den Code manuell: ' + household.invite_code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,48 +109,6 @@ export function HouseholdCard() {
|
|||||||
return <div className="bg-surface rounded-2xl p-4 shadow-sm animate-pulse h-24" />
|
return <div className="bg-surface rounded-2xl p-4 shadow-sm animate-pulse h-24" />
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mini modal component
|
|
||||||
const MiniModal = ({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
open: boolean
|
|
||||||
onClose: () => void
|
|
||||||
title: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}) => (
|
|
||||||
<AnimatePresence>
|
|
||||||
{open && (
|
|
||||||
<motion.div
|
|
||||||
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 backdrop-blur-sm"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="bg-cream w-full sm:max-w-sm sm:rounded-2xl rounded-t-2xl p-6"
|
|
||||||
initial={{ y: '100%' }}
|
|
||||||
animate={{ y: 0 }}
|
|
||||||
exit={{ y: '100%' }}
|
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="font-display text-lg text-espresso">{title}</h3>
|
|
||||||
<button onClick={onClose} className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey">
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
)
|
|
||||||
|
|
||||||
// No household
|
// No household
|
||||||
if (!household) {
|
if (!household) {
|
||||||
return (
|
return (
|
||||||
@@ -158,39 +137,80 @@ export function HouseholdCard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MiniModal open={showCreate} onClose={() => setShowCreate(false)} title="Haushalt erstellen">
|
{/* Create Modal */}
|
||||||
<input
|
{showCreate && (
|
||||||
type="text"
|
<div
|
||||||
value={name}
|
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||||
onChange={(e) => setName(e.target.value)}
|
onClick={() => setShowCreate(false)}
|
||||||
placeholder="z.B. Luna & Marc"
|
|
||||||
className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px] mb-4"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => name.trim() && createMut.mutate(name.trim())}
|
|
||||||
disabled={!name.trim() || createMut.isPending}
|
|
||||||
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{createMut.isPending ? 'Erstellen...' : 'Erstellen'}
|
<div
|
||||||
</button>
|
className="bg-cream w-full sm:max-w-sm sm:rounded-2xl rounded-t-2xl p-6"
|
||||||
</MiniModal>
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-display text-lg text-espresso">Haushalt erstellen</h3>
|
||||||
|
<button onClick={() => setShowCreate(false)} className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); name.trim() && createMut.mutate(name.trim()) }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="z.B. Luna & Marc"
|
||||||
|
autoFocus
|
||||||
|
className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px] mb-4"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!name.trim() || createMut.isPending}
|
||||||
|
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createMut.isPending ? 'Erstellen...' : 'Erstellen'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<MiniModal open={showJoin} onClose={() => setShowJoin(false)} title="Mit Code beitreten">
|
{/* Join Modal */}
|
||||||
<input
|
{showJoin && (
|
||||||
type="text"
|
<div
|
||||||
value={code}
|
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onClick={() => setShowJoin(false)}
|
||||||
placeholder="Einladungscode eingeben"
|
|
||||||
className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px] mb-4"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => code.trim() && joinMut.mutate(code.trim())}
|
|
||||||
disabled={!code.trim() || joinMut.isPending}
|
|
||||||
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{joinMut.isPending ? 'Beitreten...' : 'Beitreten'}
|
<div
|
||||||
</button>
|
className="bg-cream w-full sm:max-w-sm sm:rounded-2xl rounded-t-2xl p-6"
|
||||||
</MiniModal>
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-display text-lg text-espresso">Mit Code beitreten</h3>
|
||||||
|
<button onClick={() => setShowJoin(false)} className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); code.trim() && joinMut.mutate(code.trim()) }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
|
placeholder="Einladungscode"
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize="characters"
|
||||||
|
className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso font-mono tracking-wider placeholder:text-warm-grey/50 placeholder:font-sans placeholder:tracking-normal focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px] mb-4"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!code.trim() || joinMut.isPending}
|
||||||
|
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{joinMut.isPending ? 'Beitreten...' : 'Beitreten'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -239,12 +259,13 @@ export function HouseholdCard() {
|
|||||||
<div className="bg-sand/30 rounded-xl p-3 mb-3">
|
<div className="bg-sand/30 rounded-xl p-3 mb-3">
|
||||||
<p className="text-xs text-warm-grey mb-1">Einladungscode</p>
|
<p className="text-xs text-warm-grey mb-1">Einladungscode</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="flex-1 text-sm font-mono text-espresso bg-surface rounded-lg px-3 py-2">
|
<code className="flex-1 text-sm font-mono text-espresso bg-surface rounded-lg px-3 py-2 select-all">
|
||||||
{household.invite_code}
|
{household.invite_code}
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
onClick={copyCode}
|
onClick={copyCode}
|
||||||
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey hover:text-primary transition-colors"
|
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey hover:text-primary transition-colors"
|
||||||
|
title="Code kopieren"
|
||||||
>
|
>
|
||||||
{copied ? <Check size={18} className="text-sage" /> : <Copy size={18} />}
|
{copied ? <Check size={18} className="text-sage" /> : <Copy size={18} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -252,6 +273,7 @@ export function HouseholdCard() {
|
|||||||
onClick={() => regenMut.mutate()}
|
onClick={() => regenMut.mutate()}
|
||||||
disabled={regenMut.isPending}
|
disabled={regenMut.isPending}
|
||||||
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey hover:text-primary transition-colors disabled:opacity-50"
|
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey hover:text-primary transition-colors disabled:opacity-50"
|
||||||
|
title="Neuen Code generieren"
|
||||||
>
|
>
|
||||||
<RefreshCw size={18} className={regenMut.isPending ? 'animate-spin' : ''} />
|
<RefreshCw size={18} className={regenMut.isPending ? 'animate-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
@@ -272,45 +294,37 @@ export function HouseholdCard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Leave confirm */}
|
{/* Leave confirm */}
|
||||||
<AnimatePresence>
|
{showLeaveConfirm && (
|
||||||
{showLeaveConfirm && (
|
<div
|
||||||
<motion.div
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm px-6"
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm px-6"
|
onClick={() => setShowLeaveConfirm(false)}
|
||||||
initial={{ opacity: 0 }}
|
>
|
||||||
animate={{ opacity: 1 }}
|
<div
|
||||||
exit={{ opacity: 0 }}
|
className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl"
|
||||||
onClick={() => setShowLeaveConfirm(false)}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<motion.div
|
<h3 className="font-display text-lg text-espresso mb-2">Haushalt verlassen?</h3>
|
||||||
className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl"
|
<p className="text-warm-grey text-sm mb-5">
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
Du verlierst Zugriff auf die gemeinsame Einkaufsliste.
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
</p>
|
||||||
exit={{ scale: 0.9, opacity: 0 }}
|
<div className="flex gap-3">
|
||||||
onClick={(e) => e.stopPropagation()}
|
<button
|
||||||
>
|
onClick={() => setShowLeaveConfirm(false)}
|
||||||
<h3 className="font-display text-lg text-espresso mb-2">Haushalt verlassen?</h3>
|
className="flex-1 py-3 rounded-xl border border-sand text-espresso font-medium min-h-[44px]"
|
||||||
<p className="text-warm-grey text-sm mb-5">
|
>
|
||||||
Du verlierst Zugriff auf die gemeinsame Einkaufsliste.
|
Abbrechen
|
||||||
</p>
|
</button>
|
||||||
<div className="flex gap-3">
|
<button
|
||||||
<button
|
onClick={() => leaveMut.mutate()}
|
||||||
onClick={() => setShowLeaveConfirm(false)}
|
disabled={leaveMut.isPending}
|
||||||
className="flex-1 py-3 rounded-xl border border-sand text-espresso font-medium min-h-[44px]"
|
className="flex-1 py-3 rounded-xl bg-berry-red text-white font-medium min-h-[44px] disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Abbrechen
|
{leaveMut.isPending ? 'Wird verlassen...' : 'Verlassen'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
</div>
|
||||||
onClick={() => leaveMut.mutate()}
|
</div>
|
||||||
disabled={leaveMut.isPending}
|
</div>
|
||||||
className="flex-1 py-3 rounded-xl bg-berry-red text-white font-medium min-h-[44px] disabled:opacity-50"
|
)}
|
||||||
>
|
|
||||||
{leaveMut.isPending ? 'Wird verlassen...' : 'Verlassen'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef, useMemo } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Trash2, Plus } from 'lucide-react'
|
import { Trash2, Plus, List, Layers } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
fetchShopping,
|
fetchShopping,
|
||||||
addCustomItem,
|
addCustomItem,
|
||||||
@@ -13,14 +13,72 @@ import type { ShoppingItem } from '../api/shopping'
|
|||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { EmptyState } from '../components/ui/EmptyState'
|
import { EmptyState } from '../components/ui/EmptyState'
|
||||||
|
|
||||||
|
// Merged item for combined view
|
||||||
|
interface MergedItem {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
totalAmount: number | null
|
||||||
|
unit: string | null
|
||||||
|
items: ShoppingItem[] // original items that were merged
|
||||||
|
allChecked: boolean
|
||||||
|
someChecked: boolean
|
||||||
|
recipes: string[] // recipe names
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeItems(groups: { recipe_title: string; recipe_id?: string; items: ShoppingItem[] }[]): MergedItem[] {
|
||||||
|
const allItems = groups.flatMap((g) =>
|
||||||
|
g.items.map((item) => ({ ...item, recipe_title: g.recipe_title, recipe_id: g.recipe_id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const merged = new Map<string, MergedItem>()
|
||||||
|
|
||||||
|
for (const item of allItems) {
|
||||||
|
const normalizedName = item.name.trim().toLowerCase()
|
||||||
|
const normalizedUnit = (item.unit || '').trim().toLowerCase()
|
||||||
|
const key = `${normalizedName}::${normalizedUnit}`
|
||||||
|
|
||||||
|
if (merged.has(key)) {
|
||||||
|
const existing = merged.get(key)!
|
||||||
|
existing.items.push(item)
|
||||||
|
if (item.amount != null && existing.totalAmount != null) {
|
||||||
|
existing.totalAmount += item.amount
|
||||||
|
} else if (item.amount != null) {
|
||||||
|
existing.totalAmount = item.amount
|
||||||
|
}
|
||||||
|
if (item.recipe_title && !existing.recipes.includes(item.recipe_title)) {
|
||||||
|
existing.recipes.push(item.recipe_title)
|
||||||
|
}
|
||||||
|
existing.allChecked = existing.items.every((i) => i.checked)
|
||||||
|
existing.someChecked = existing.items.some((i) => i.checked)
|
||||||
|
} else {
|
||||||
|
merged.set(key, {
|
||||||
|
key,
|
||||||
|
name: item.name, // keep original casing from first occurrence
|
||||||
|
totalAmount: item.amount ?? null,
|
||||||
|
unit: item.unit || null,
|
||||||
|
items: [item],
|
||||||
|
allChecked: !!item.checked,
|
||||||
|
someChecked: !!item.checked,
|
||||||
|
recipes: item.recipe_title ? [item.recipe_title] : [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: unchecked first, then by name
|
||||||
|
return Array.from(merged.values()).sort((a, b) => {
|
||||||
|
if (a.allChecked !== b.allChecked) return a.allChecked ? 1 : -1
|
||||||
|
return a.name.localeCompare(b.name, 'de')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function ShoppingPage() {
|
export function ShoppingPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { isAuthenticated } = useAuth()
|
const { isAuthenticated } = useAuth()
|
||||||
const [newItem, setNewItem] = useState('')
|
const [newItem, setNewItem] = useState('')
|
||||||
const [scope, setScope] = useState<'personal' | 'household'>('personal')
|
const [scope, setScope] = useState<'personal' | 'household'>('personal')
|
||||||
|
const [viewMode, setViewMode] = useState<'recipe' | 'merged'>('merged')
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Check if user has a household
|
|
||||||
const { data: householdData } = useQuery({
|
const { data: householdData } = useQuery({
|
||||||
queryKey: ['household'],
|
queryKey: ['household'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -44,6 +102,8 @@ export function ShoppingPage() {
|
|||||||
queryFn: () => fetchShopping(activeScope),
|
queryFn: () => fetchShopping(activeScope),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mergedItems = useMemo(() => mergeItems(groups), [groups])
|
||||||
|
|
||||||
const checkMutation = useMutation({
|
const checkMutation = useMutation({
|
||||||
mutationFn: toggleCheck,
|
mutationFn: toggleCheck,
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
|
||||||
@@ -81,6 +141,24 @@ export function ShoppingPage() {
|
|||||||
addMutation.mutate({ name })
|
addMutation.mutate({ name })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle all items in a merged group
|
||||||
|
const handleMergedToggle = async (merged: MergedItem) => {
|
||||||
|
// If all checked → uncheck all, otherwise check all unchecked
|
||||||
|
const itemsToToggle = merged.allChecked
|
||||||
|
? merged.items
|
||||||
|
: merged.items.filter((i) => !i.checked)
|
||||||
|
for (const item of itemsToToggle) {
|
||||||
|
checkMutation.mutate(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all items in a merged group
|
||||||
|
const handleMergedDelete = async (merged: MergedItem) => {
|
||||||
|
for (const item of merged.items) {
|
||||||
|
deleteMutation.mutate(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hasChecked = groups.some((g) => g.items.some((i) => i.checked))
|
const hasChecked = groups.some((g) => g.items.some((i) => i.checked))
|
||||||
const totalItems = groups.reduce((acc, g) => acc + g.items.length, 0)
|
const totalItems = groups.reduce((acc, g) => acc + g.items.length, 0)
|
||||||
const totalChecked = groups.reduce((acc, g) => acc + g.items.filter((i) => i.checked).length, 0)
|
const totalChecked = groups.reduce((acc, g) => acc + g.items.filter((i) => i.checked).length, 0)
|
||||||
@@ -135,9 +213,17 @@ export function ShoppingPage() {
|
|||||||
{/* TopBar */}
|
{/* TopBar */}
|
||||||
<div className="sticky top-0 z-40 bg-cream/95 backdrop-blur-sm border-b border-sand px-4 py-3 flex items-center justify-between">
|
<div className="sticky top-0 z-40 bg-cream/95 backdrop-blur-sm border-b border-sand px-4 py-3 flex items-center justify-between">
|
||||||
<h1 className="font-display text-xl text-espresso">Einkaufsliste</h1>
|
<h1 className="font-display text-xl text-espresso">Einkaufsliste</h1>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
|
{/* View toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode(viewMode === 'merged' ? 'recipe' : 'merged')}
|
||||||
|
className="p-2 text-warm-grey hover:text-primary transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||||
|
title={viewMode === 'merged' ? 'Nach Rezept anzeigen' : 'Zusammengefasst anzeigen'}
|
||||||
|
>
|
||||||
|
{viewMode === 'merged' ? <List size={18} /> : <Layers size={18} />}
|
||||||
|
</button>
|
||||||
{totalUnchecked > 0 && (
|
{totalUnchecked > 0 && (
|
||||||
<span className="text-sm text-warm-grey">{totalUnchecked} offen</span>
|
<span className="text-sm text-warm-grey">{totalUnchecked}</span>
|
||||||
)}
|
)}
|
||||||
{hasChecked && (
|
{hasChecked && (
|
||||||
<button
|
<button
|
||||||
@@ -167,9 +253,7 @@ export function ShoppingPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setScope('personal')}
|
onClick={() => setScope('personal')}
|
||||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors min-h-[36px] ${
|
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors min-h-[36px] ${
|
||||||
scope === 'personal'
|
scope === 'personal' ? 'bg-primary text-white' : 'text-warm-grey hover:text-espresso'
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'text-warm-grey hover:text-espresso'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Persönlich
|
Persönlich
|
||||||
@@ -177,9 +261,7 @@ export function ShoppingPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setScope('household')}
|
onClick={() => setScope('household')}
|
||||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors min-h-[36px] ${
|
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors min-h-[36px] ${
|
||||||
scope === 'household'
|
scope === 'household' ? 'bg-primary text-white' : 'text-warm-grey hover:text-espresso'
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'text-warm-grey hover:text-espresso'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
🏠 Haushalt
|
🏠 Haushalt
|
||||||
@@ -202,10 +284,7 @@ export function ShoppingPage() {
|
|||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => { deleteAllMutation.mutate(); setShowClearConfirm(false) }}
|
||||||
deleteAllMutation.mutate()
|
|
||||||
setShowClearConfirm(false)
|
|
||||||
}}
|
|
||||||
className="flex-1 py-3 rounded-xl bg-berry-red text-white font-medium min-h-[44px]"
|
className="flex-1 py-3 rounded-xl bg-berry-red text-white font-medium min-h-[44px]"
|
||||||
>
|
>
|
||||||
🗑️ Alles löschen
|
🗑️ Alles löschen
|
||||||
@@ -221,13 +300,7 @@ export function ShoppingPage() {
|
|||||||
|
|
||||||
{/* Quick-Add */}
|
{/* Quick-Add */}
|
||||||
<div className={`sticky ${hasHousehold ? 'top-[105px]' : 'top-[53px]'} z-30 bg-cream/95 backdrop-blur-sm px-4 py-3`}>
|
<div className={`sticky ${hasHousehold ? 'top-[105px]' : 'top-[53px]'} z-30 bg-cream/95 backdrop-blur-sm px-4 py-3`}>
|
||||||
<form
|
<form onSubmit={(e) => { e.preventDefault(); handleAdd() }} className="flex gap-2">
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
handleAdd()
|
|
||||||
}}
|
|
||||||
className="flex gap-2"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -252,8 +325,10 @@ export function ShoppingPage() {
|
|||||||
<div className="bg-primary-light/30 rounded-2xl p-3">
|
<div className="bg-primary-light/30 rounded-2xl p-3">
|
||||||
<div className="flex items-center justify-between text-sm text-espresso">
|
<div className="flex items-center justify-between text-sm text-espresso">
|
||||||
<span>
|
<span>
|
||||||
{recipeCount > 0 && <>{recipeCount} Rezept{recipeCount !== 1 ? 'e' : ''} · </>}
|
{viewMode === 'merged'
|
||||||
{totalItems} Artikel · {totalChecked} erledigt
|
? <>{mergedItems.length} Artikel{mergedItems.length !== 1 ? '' : ''} · {totalChecked} erledigt</>
|
||||||
|
: <>{recipeCount > 0 && <>{recipeCount} Rezept{recipeCount !== 1 ? 'e' : ''} · </>}{totalItems} Artikel · {totalChecked} erledigt</>
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-warm-grey text-xs">
|
<span className="text-warm-grey text-xs">
|
||||||
{totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0}%
|
{totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0}%
|
||||||
@@ -277,7 +352,29 @@ export function ShoppingPage() {
|
|||||||
title="Einkaufsliste leer"
|
title="Einkaufsliste leer"
|
||||||
description="Füge Zutaten aus einem Rezept hinzu oder erstelle eigene Einträge."
|
description="Füge Zutaten aus einem Rezept hinzu oder erstelle eigene Einträge."
|
||||||
/>
|
/>
|
||||||
|
) : viewMode === 'merged' ? (
|
||||||
|
/* ── Merged/Combined View ── */
|
||||||
|
<div className="bg-surface rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-sand/50 flex items-center gap-2">
|
||||||
|
<span className="text-base">🛒</span>
|
||||||
|
<h3 className="font-semibold text-sm text-espresso">Zusammengefasst</h3>
|
||||||
|
<span className="text-xs text-warm-grey ml-auto">
|
||||||
|
{mergedItems.filter((m) => !m.allChecked).length}/{mergedItems.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{mergedItems.map((merged) => (
|
||||||
|
<MergedItemRow
|
||||||
|
key={merged.key}
|
||||||
|
merged={merged}
|
||||||
|
onToggle={() => handleMergedToggle(merged)}
|
||||||
|
onDelete={() => handleMergedDelete(merged)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
/* ── Recipe-grouped View ── */
|
||||||
groups.map((group) => (
|
groups.map((group) => (
|
||||||
<div key={group.recipe_title} className="bg-surface rounded-2xl shadow-sm overflow-hidden">
|
<div key={group.recipe_title} className="bg-surface rounded-2xl shadow-sm overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b border-sand/50 flex items-center gap-2">
|
<div className="px-4 py-3 border-b border-sand/50 flex items-center gap-2">
|
||||||
@@ -307,6 +404,115 @@ export function ShoppingPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Merged Item Row ── */
|
||||||
|
function MergedItemRow({
|
||||||
|
merged,
|
||||||
|
onToggle,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
merged: MergedItem
|
||||||
|
onToggle: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
const THRESHOLD = -80
|
||||||
|
const [swipeX, setSwipeX] = useState(0)
|
||||||
|
const touchStartX = useRef(0)
|
||||||
|
const swiping = useRef(false)
|
||||||
|
const pastThreshold = swipeX < THRESHOLD
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
touchStartX.current = e.touches[0].clientX
|
||||||
|
swiping.current = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
|
const dx = e.touches[0].clientX - touchStartX.current
|
||||||
|
if (dx < -10) {
|
||||||
|
swiping.current = true
|
||||||
|
setSwipeX(Math.max(dx, -160))
|
||||||
|
} else {
|
||||||
|
setSwipeX(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (pastThreshold) {
|
||||||
|
setSwipeX(-300)
|
||||||
|
setTimeout(() => onDelete(), 200)
|
||||||
|
} else {
|
||||||
|
setSwipeX(0)
|
||||||
|
}
|
||||||
|
swiping.current = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountText = [
|
||||||
|
merged.totalAmount != null ? (Number.isInteger(merged.totalAmount) ? merged.totalAmount : merged.totalAmount.toFixed(1)) : null,
|
||||||
|
merged.unit,
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
const isChecked = merged.allChecked
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="relative overflow-hidden">
|
||||||
|
<div className={`absolute inset-y-0 right-0 w-40 flex items-center justify-center transition-colors ${pastThreshold ? 'bg-berry-red' : 'bg-berry-red/60'}`}>
|
||||||
|
<span className={`text-white font-medium transition-all ${pastThreshold ? 'text-sm scale-110' : 'text-xs'}`}>
|
||||||
|
{pastThreshold ? '🗑️ Löschen' : '×'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`relative bg-surface flex items-center gap-3 px-4 min-h-[52px] ${swiping.current ? '' : 'transition-transform duration-200'}`}
|
||||||
|
style={{ transform: `translateX(${swipeX}px)` }}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex-shrink-0 w-6 h-6 rounded-md border-2 flex items-center justify-center transition-colors min-w-[44px] min-h-[44px]"
|
||||||
|
style={{
|
||||||
|
borderColor: isChecked ? '#C4737E' : merged.someChecked ? '#C4737E80' : '#E8E0D8',
|
||||||
|
backgroundColor: isChecked ? '#C4737E' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isChecked && (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M2.5 7L5.5 10L11.5 4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{merged.someChecked && !isChecked && (
|
||||||
|
<div className="w-2 h-2 rounded-sm bg-primary/60" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={`flex-1 min-w-0 ${isChecked ? 'opacity-50' : ''}`}>
|
||||||
|
<span className={`text-base sm:text-lg text-espresso ${isChecked ? 'line-through text-warm-grey' : ''}`}>
|
||||||
|
{merged.name}
|
||||||
|
</span>
|
||||||
|
{merged.items.length > 1 && (
|
||||||
|
<p className="text-xs text-warm-grey truncate">
|
||||||
|
{merged.recipes.length > 0 ? merged.recipes.join(', ') : `${merged.items.length}×`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{amountText && (
|
||||||
|
<span className={`text-sm flex-shrink-0 font-medium ${isChecked ? 'text-warm-grey/50 line-through' : 'text-warm-grey'}`}>
|
||||||
|
{amountText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{merged.items.length > 1 && !isChecked && (
|
||||||
|
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded-full flex-shrink-0">
|
||||||
|
{merged.items.length}×
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Single Item Row ── */
|
||||||
function ShoppingItemRow({
|
function ShoppingItemRow({
|
||||||
item,
|
item,
|
||||||
onToggle,
|
onToggle,
|
||||||
@@ -351,24 +557,14 @@ function ShoppingItemRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="relative overflow-hidden">
|
<li className="relative overflow-hidden">
|
||||||
<div
|
<div className={`absolute inset-y-0 right-0 w-40 flex items-center justify-center transition-colors ${pastThreshold ? 'bg-berry-red' : 'bg-berry-red/60'}`}>
|
||||||
className={`absolute inset-y-0 right-0 w-40 flex items-center justify-center transition-colors ${
|
<span className={`text-white font-medium transition-all ${pastThreshold ? 'text-sm scale-110' : 'text-xs'}`}>
|
||||||
pastThreshold ? 'bg-berry-red' : 'bg-berry-red/60'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`text-white font-medium transition-all ${
|
|
||||||
pastThreshold ? 'text-sm scale-110' : 'text-xs'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pastThreshold ? '🗑️ Löschen' : '×'}
|
{pastThreshold ? '🗑️ Löschen' : '×'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`relative bg-surface flex items-center gap-3 px-4 min-h-[52px] ${
|
className={`relative bg-surface flex items-center gap-3 px-4 min-h-[52px] ${swiping.current ? '' : 'transition-transform duration-200'}`}
|
||||||
swiping.current ? '' : 'transition-transform duration-200'
|
|
||||||
}`}
|
|
||||||
style={{ transform: `translateX(${swipeX}px)` }}
|
style={{ transform: `translateX(${swipeX}px)` }}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
@@ -384,33 +580,19 @@ function ShoppingItemRow({
|
|||||||
>
|
>
|
||||||
{item.checked && (
|
{item.checked && (
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
<path
|
<path d="M2.5 7L5.5 10L11.5 4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
d="M2.5 7L5.5 10L11.5 4"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className={`flex-1 min-w-0 ${item.checked ? 'opacity-50' : ''}`}>
|
<div className={`flex-1 min-w-0 ${item.checked ? 'opacity-50' : ''}`}>
|
||||||
<span
|
<span className={`text-base sm:text-lg text-espresso ${item.checked ? 'line-through text-warm-grey' : ''}`}>
|
||||||
className={`text-base sm:text-lg text-espresso ${
|
|
||||||
item.checked ? 'line-through text-warm-grey' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.name}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{amountText && (
|
{amountText && (
|
||||||
<span
|
<span className={`text-sm flex-shrink-0 ${item.checked ? 'text-warm-grey/50 line-through' : 'text-warm-grey'}`}>
|
||||||
className={`text-sm flex-shrink-0 ${
|
|
||||||
item.checked ? 'text-warm-grey/50 line-through' : 'text-warm-grey'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{amountText}
|
{amountText}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user