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:
clawd
2026-02-18 17:56:29 +00:00
parent cc8e2482e9
commit 03f3893c2c
4 changed files with 381 additions and 175 deletions

View File

@@ -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:

View File

@@ -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;

View File

@@ -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 */}
{showCreate && (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={() => setShowCreate(false)}
>
<div
className="bg-cream w-full sm:max-w-sm sm:rounded-2xl rounded-t-2xl p-6"
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 <input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="z.B. Luna & Marc" 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" 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 <button
onClick={() => name.trim() && createMut.mutate(name.trim())} type="submit"
disabled={!name.trim() || createMut.isPending} disabled={!name.trim() || createMut.isPending}
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50" className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50"
> >
{createMut.isPending ? 'Erstellen...' : 'Erstellen'} {createMut.isPending ? 'Erstellen...' : 'Erstellen'}
</button> </button>
</MiniModal> </form>
</div>
</div>
)}
<MiniModal open={showJoin} onClose={() => setShowJoin(false)} title="Mit Code beitreten"> {/* Join Modal */}
{showJoin && (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={() => setShowJoin(false)}
>
<div
className="bg-cream w-full sm:max-w-sm sm:rounded-2xl rounded-t-2xl p-6"
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 <input
type="text" type="text"
value={code} value={code}
onChange={(e) => setCode(e.target.value)} onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="Einladungscode eingeben" placeholder="Einladungscode"
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" 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 <button
onClick={() => code.trim() && joinMut.mutate(code.trim())} type="submit"
disabled={!code.trim() || joinMut.isPending} disabled={!code.trim() || joinMut.isPending}
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50" className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50"
> >
{joinMut.isPending ? 'Beitreten...' : 'Beitreten'} {joinMut.isPending ? 'Beitreten...' : 'Beitreten'}
</button> </button>
</MiniModal> </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,20 +294,13 @@ export function HouseholdCard() {
</div> </div>
{/* Leave confirm */} {/* Leave confirm */}
<AnimatePresence>
{showLeaveConfirm && ( {showLeaveConfirm && (
<motion.div <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"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowLeaveConfirm(false)} onClick={() => setShowLeaveConfirm(false)}
> >
<motion.div <div
className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl" className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h3 className="font-display text-lg text-espresso mb-2">Haushalt verlassen?</h3> <h3 className="font-display text-lg text-espresso mb-2">Haushalt verlassen?</h3>
@@ -307,10 +322,9 @@ export function HouseholdCard() {
{leaveMut.isPending ? 'Wird verlassen...' : 'Verlassen'} {leaveMut.isPending ? 'Wird verlassen...' : 'Verlassen'}
</button> </button>
</div> </div>
</motion.div> </div>
</motion.div> </div>
)} )}
</AnimatePresence>
</> </>
) )
} }

View File

@@ -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>
)} )}