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:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend/data:/app/data
|
||||
environment:
|
||||
DATABASE_URL: postgresql://luna:${DB_PASSWORD:-luna-recipes-secret-2026}@db:5432/luna_recipes
|
||||
JWT_SECRET: ${JWT_SECRET:-luna-jwt-change-in-prod}
|
||||
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-luna-refresh-change-in-prod}
|
||||
COOKIE_SECRET: ${COOKIE_SECRET:-luna-cookie-change-in-prod}
|
||||
JWT_SECRET: ${JWT_SECRET:-luna-recipes-jwt-secret-change-in-prod-2026}
|
||||
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-luna-recipes-refresh-secret-change-in-prod-2026}
|
||||
COOKIE_SECRET: ${COOKIE_SECRET:-luna-recipes-cookie-secret-change-in-prod-2026}
|
||||
PORT: "6001"
|
||||
NODE_ENV: production
|
||||
ports:
|
||||
|
||||
@@ -4,6 +4,14 @@ server {
|
||||
root /usr/share/nginx/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
|
||||
location /api/ {
|
||||
proxy_pass http://backend:6001;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
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 {
|
||||
getMyHousehold,
|
||||
@@ -23,13 +22,20 @@ export function HouseholdCard() {
|
||||
const [code, setCode] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data: household, isLoading } = useQuery({
|
||||
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,
|
||||
})
|
||||
|
||||
const household: Household | null = data?.data ?? null
|
||||
const myRole = household?.members.find((m) => m.user_id === user?.id)?.role
|
||||
|
||||
const createMut = useMutation({
|
||||
@@ -73,14 +79,29 @@ export function HouseholdCard() {
|
||||
onError: (err: Error) => showToast.error(err.message),
|
||||
})
|
||||
|
||||
const copyCode = async () => {
|
||||
// Clipboard fallback for HTTP (no navigator.clipboard)
|
||||
const copyCode = () => {
|
||||
if (!household?.invite_code) return
|
||||
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)
|
||||
showToast.success('Code kopiert!')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} 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" />
|
||||
}
|
||||
|
||||
// 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
|
||||
if (!household) {
|
||||
return (
|
||||
@@ -158,39 +137,80 @@ export function HouseholdCard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MiniModal open={showCreate} onClose={() => setShowCreate(false)} title="Haushalt erstellen">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
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"
|
||||
{/* 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)}
|
||||
>
|
||||
{createMut.isPending ? 'Erstellen...' : 'Erstellen'}
|
||||
</button>
|
||||
</MiniModal>
|
||||
<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
|
||||
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">
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
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"
|
||||
{/* 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)}
|
||||
>
|
||||
{joinMut.isPending ? 'Beitreten...' : 'Beitreten'}
|
||||
</button>
|
||||
</MiniModal>
|
||||
<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
|
||||
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">
|
||||
<p className="text-xs text-warm-grey mb-1">Einladungscode</p>
|
||||
<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}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyCode}
|
||||
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} />}
|
||||
</button>
|
||||
@@ -252,6 +273,7 @@ export function HouseholdCard() {
|
||||
onClick={() => regenMut.mutate()}
|
||||
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"
|
||||
title="Neuen Code generieren"
|
||||
>
|
||||
<RefreshCw size={18} className={regenMut.isPending ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
@@ -272,45 +294,37 @@ export function HouseholdCard() {
|
||||
</div>
|
||||
|
||||
{/* Leave confirm */}
|
||||
<AnimatePresence>
|
||||
{showLeaveConfirm && (
|
||||
<motion.div
|
||||
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)}
|
||||
{showLeaveConfirm && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm px-6"
|
||||
onClick={() => setShowLeaveConfirm(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<motion.div
|
||||
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()}
|
||||
>
|
||||
<h3 className="font-display text-lg text-espresso mb-2">Haushalt verlassen?</h3>
|
||||
<p className="text-warm-grey text-sm mb-5">
|
||||
Du verlierst Zugriff auf die gemeinsame Einkaufsliste.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowLeaveConfirm(false)}
|
||||
className="flex-1 py-3 rounded-xl border border-sand text-espresso font-medium min-h-[44px]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => leaveMut.mutate()}
|
||||
disabled={leaveMut.isPending}
|
||||
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>
|
||||
<h3 className="font-display text-lg text-espresso mb-2">Haushalt verlassen?</h3>
|
||||
<p className="text-warm-grey text-sm mb-5">
|
||||
Du verlierst Zugriff auf die gemeinsame Einkaufsliste.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowLeaveConfirm(false)}
|
||||
className="flex-1 py-3 rounded-xl border border-sand text-espresso font-medium min-h-[44px]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => leaveMut.mutate()}
|
||||
disabled={leaveMut.isPending}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useState, useRef, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Trash2, Plus } from 'lucide-react'
|
||||
import { Trash2, Plus, List, Layers } from 'lucide-react'
|
||||
import {
|
||||
fetchShopping,
|
||||
addCustomItem,
|
||||
@@ -13,14 +13,72 @@ import type { ShoppingItem } from '../api/shopping'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
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() {
|
||||
const qc = useQueryClient()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const [newItem, setNewItem] = useState('')
|
||||
const [scope, setScope] = useState<'personal' | 'household'>('personal')
|
||||
const [viewMode, setViewMode] = useState<'recipe' | 'merged'>('merged')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Check if user has a household
|
||||
const { data: householdData } = useQuery({
|
||||
queryKey: ['household'],
|
||||
queryFn: async () => {
|
||||
@@ -44,6 +102,8 @@ export function ShoppingPage() {
|
||||
queryFn: () => fetchShopping(activeScope),
|
||||
})
|
||||
|
||||
const mergedItems = useMemo(() => mergeItems(groups), [groups])
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: toggleCheck,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
|
||||
@@ -81,6 +141,24 @@ export function ShoppingPage() {
|
||||
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 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)
|
||||
@@ -135,9 +213,17 @@ export function ShoppingPage() {
|
||||
{/* 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">
|
||||
<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 && (
|
||||
<span className="text-sm text-warm-grey">{totalUnchecked} offen</span>
|
||||
<span className="text-sm text-warm-grey">{totalUnchecked}</span>
|
||||
)}
|
||||
{hasChecked && (
|
||||
<button
|
||||
@@ -167,9 +253,7 @@ export function ShoppingPage() {
|
||||
<button
|
||||
onClick={() => setScope('personal')}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors min-h-[36px] ${
|
||||
scope === 'personal'
|
||||
? 'bg-primary text-white'
|
||||
: 'text-warm-grey hover:text-espresso'
|
||||
scope === 'personal' ? 'bg-primary text-white' : 'text-warm-grey hover:text-espresso'
|
||||
}`}
|
||||
>
|
||||
Persönlich
|
||||
@@ -177,9 +261,7 @@ export function ShoppingPage() {
|
||||
<button
|
||||
onClick={() => setScope('household')}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors min-h-[36px] ${
|
||||
scope === 'household'
|
||||
? 'bg-primary text-white'
|
||||
: 'text-warm-grey hover:text-espresso'
|
||||
scope === 'household' ? 'bg-primary text-white' : 'text-warm-grey hover:text-espresso'
|
||||
}`}
|
||||
>
|
||||
🏠 Haushalt
|
||||
@@ -202,10 +284,7 @@ export function ShoppingPage() {
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteAllMutation.mutate()
|
||||
setShowClearConfirm(false)
|
||||
}}
|
||||
onClick={() => { deleteAllMutation.mutate(); setShowClearConfirm(false) }}
|
||||
className="flex-1 py-3 rounded-xl bg-berry-red text-white font-medium min-h-[44px]"
|
||||
>
|
||||
🗑️ Alles löschen
|
||||
@@ -221,13 +300,7 @@ export function ShoppingPage() {
|
||||
|
||||
{/* Quick-Add */}
|
||||
<div className={`sticky ${hasHousehold ? 'top-[105px]' : 'top-[53px]'} z-30 bg-cream/95 backdrop-blur-sm px-4 py-3`}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleAdd()
|
||||
}}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleAdd() }} className="flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
@@ -252,8 +325,10 @@ export function ShoppingPage() {
|
||||
<div className="bg-primary-light/30 rounded-2xl p-3">
|
||||
<div className="flex items-center justify-between text-sm text-espresso">
|
||||
<span>
|
||||
{recipeCount > 0 && <>{recipeCount} Rezept{recipeCount !== 1 ? 'e' : ''} · </>}
|
||||
{totalItems} Artikel · {totalChecked} erledigt
|
||||
{viewMode === 'merged'
|
||||
? <>{mergedItems.length} Artikel{mergedItems.length !== 1 ? '' : ''} · {totalChecked} erledigt</>
|
||||
: <>{recipeCount > 0 && <>{recipeCount} Rezept{recipeCount !== 1 ? 'e' : ''} · </>}{totalItems} Artikel · {totalChecked} erledigt</>
|
||||
}
|
||||
</span>
|
||||
<span className="text-warm-grey text-xs">
|
||||
{totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0}%
|
||||
@@ -277,7 +352,29 @@ export function ShoppingPage() {
|
||||
title="Einkaufsliste leer"
|
||||
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) => (
|
||||
<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">
|
||||
@@ -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({
|
||||
item,
|
||||
onToggle,
|
||||
@@ -351,24 +557,14 @@ function ShoppingItemRow({
|
||||
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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'
|
||||
}`}
|
||||
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}
|
||||
@@ -384,33 +580,19 @@ function ShoppingItemRow({
|
||||
>
|
||||
{item.checked && (
|
||||
<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"
|
||||
/>
|
||||
<path d="M2.5 7L5.5 10L11.5 4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className={`flex-1 min-w-0 ${item.checked ? 'opacity-50' : ''}`}>
|
||||
<span
|
||||
className={`text-base sm:text-lg text-espresso ${
|
||||
item.checked ? 'line-through text-warm-grey' : ''
|
||||
}`}
|
||||
>
|
||||
<span className={`text-base sm:text-lg text-espresso ${item.checked ? 'line-through text-warm-grey' : ''}`}>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{amountText && (
|
||||
<span
|
||||
className={`text-sm flex-shrink-0 ${
|
||||
item.checked ? 'text-warm-grey/50 line-through' : 'text-warm-grey'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-sm flex-shrink-0 ${item.checked ? 'text-warm-grey/50 line-through' : 'text-warm-grey'}`}>
|
||||
{amountText}
|
||||
</span>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user