Auth v2: Register/Login/Profile, Households, per-user Favorites/Notes/Shopping, Frontend Auth Pages

This commit is contained in:
clawd
2026-02-18 15:47:13 +00:00
parent b0bd3e533f
commit 30e44370a1
32 changed files with 3561 additions and 113 deletions

View File

@@ -1,5 +1,7 @@
import { BrowserRouter, Routes, Route } from 'react-router'
import { AppShell } from './components/layout/AppShell'
import { AuthProvider } from './context/AuthContext'
import { PublicRoute } from './components/auth/AuthGuard'
import { HomePage } from './pages/HomePage'
import { RecipePage } from './pages/RecipePage'
import { SearchPage } from './pages/SearchPage'
@@ -7,21 +9,44 @@ import { PlaceholderPage } from './pages/PlaceholderPage'
import { ProfilePage } from './pages/ProfilePage'
import { RecipeFormPage } from './pages/RecipeFormPage'
import { ShoppingPage } from './pages/ShoppingPage'
import { LoginPage } from './pages/LoginPage'
import { RegisterPage } from './pages/RegisterPage'
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<AppShell />}>
<Route index element={<HomePage />} />
<Route path="recipe/:slug" element={<RecipePage />} />
<Route path="search" element={<SearchPage />} />
<Route path="new" element={<RecipeFormPage />} />
<Route path="recipe/:slug/edit" element={<RecipeFormPage />} />
<Route path="shopping" element={<ShoppingPage />} />
<Route path="profile" element={<ProfilePage />} />
</Route>
</Routes>
</BrowserRouter>
<AuthProvider>
<BrowserRouter>
<Routes>
{/* Public Auth Routes */}
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
<Route
path="/register"
element={
<PublicRoute>
<RegisterPage />
</PublicRoute>
}
/>
{/* Main App Routes */}
<Route element={<AppShell />}>
<Route index element={<HomePage />} />
<Route path="recipe/:slug" element={<RecipePage />} />
<Route path="search" element={<SearchPage />} />
<Route path="new" element={<RecipeFormPage />} />
<Route path="recipe/:slug/edit" element={<RecipeFormPage />} />
<Route path="shopping" element={<ShoppingPage />} />
<Route path="profile" element={<ProfilePage />} />
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
)
}

View File

@@ -1,21 +1,100 @@
import { apiFetch } from './client'
// v2: Auth API — placeholder functions
export interface User {
id: string
name: string
email?: string
email: string
display_name: string
avatar_url?: string
created_at: string
updated_at: string
}
export function login(_email: string, _password: string) {
return apiFetch<User>('/auth/login', { method: 'POST', body: JSON.stringify({ email: _email, password: _password }) })
export interface AuthResponse {
user: User
access_token: string
}
export function register(_email: string, _password: string, _name: string) {
return apiFetch<User>('/auth/register', { method: 'POST', body: JSON.stringify({ email: _email, password: _password, name: _name }) })
export interface RegisterData {
email: string
password: string
display_name: string
}
export function fetchMe() {
return apiFetch<User>('/auth/me')
export interface LoginData {
email: string
password: string
}
export interface UpdateProfileData {
display_name?: string
avatar_url?: string
}
export interface ChangePasswordData {
current_password: string
new_password: string
}
// Auth token storage (in memory, not localStorage)
let authToken: string | null = null
export function setAuthToken(token: string | null) {
authToken = token
}
export function getAuthToken() {
return authToken
}
// Add Authorization header to apiFetch when token exists
const authFetch = <T>(path: string, options?: RequestInit): Promise<T> => {
const headers = { ...options?.headers } as Record<string, string>
if (authToken) {
headers.Authorization = `Bearer ${authToken}`
}
return apiFetch(path, { ...options, headers })
}
export function register(data: RegisterData): Promise<AuthResponse> {
return apiFetch<AuthResponse>('/auth/register', {
method: 'POST',
body: JSON.stringify(data)
})
}
export function login(data: LoginData): Promise<AuthResponse> {
return apiFetch<AuthResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify(data)
})
}
export function logout(): Promise<void> {
return authFetch<void>('/auth/logout', {
method: 'POST'
})
}
export function getMe(): Promise<User> {
return authFetch<User>('/auth/me')
}
export function updateProfile(data: UpdateProfileData): Promise<User> {
return authFetch<User>('/auth/me', {
method: 'PUT',
body: JSON.stringify(data)
})
}
export function changePassword(data: ChangePasswordData): Promise<void> {
return authFetch<void>('/auth/me/password', {
method: 'PUT',
body: JSON.stringify(data)
})
}
export function refreshToken(): Promise<AuthResponse> {
return apiFetch<AuthResponse>('/auth/refresh', {
method: 'POST'
})
}

View File

@@ -40,6 +40,10 @@ export function deleteItem(id: string) {
return apiFetch<void>(`/shopping/${id}`, { method: 'DELETE' })
}
export function deleteAll() {
return apiFetch<void>('/shopping/all', { method: 'DELETE' })
}
export function deleteChecked() {
return apiFetch<void>('/shopping/checked', { method: 'DELETE' })
}

View File

@@ -0,0 +1,59 @@
import { type ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router'
import { useAuth } from '../../context/AuthContext'
import { Skeleton } from '../ui/Skeleton'
interface ProtectedRouteProps {
children: ReactNode
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuth()
const location = useLocation()
if (isLoading) {
return (
<div className="min-h-screen p-4">
<div className="max-w-md mx-auto space-y-4">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
)
}
if (!isAuthenticated) {
// Redirect to login and remember where they were trying to go
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}
interface PublicRouteProps {
children: ReactNode
}
export function PublicRoute({ children }: PublicRouteProps) {
const { isAuthenticated, isLoading } = useAuth()
if (isLoading) {
return (
<div className="min-h-screen p-4">
<div className="max-w-md mx-auto space-y-4">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
)
}
if (isAuthenticated) {
// Already logged in, redirect to home
return <Navigate to="/" replace />
}
return <>{children}</>
}

View File

@@ -0,0 +1,117 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
import {
User,
getMe,
setAuthToken,
getAuthToken,
refreshToken,
logout as apiLogout
} from '../api/auth'
interface AuthContextType {
user: User | null
isAuthenticated: boolean
isLoading: boolean
login: (token: string, user: User) => void
logout: () => void
updateUser: (user: User) => void
}
const AuthContext = createContext<AuthContextType | null>(null)
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
interface AuthProviderProps {
children: ReactNode
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const isAuthenticated = !!user
// Initialize auth on app start
useEffect(() => {
let mounted = true
async function initAuth() {
try {
// Try to refresh token first (cookie-based)
const authResponse = await refreshToken()
if (mounted) {
setAuthToken(authResponse.access_token)
setUser(authResponse.user)
}
} catch (error) {
// If refresh fails, check if we already have a token
const existingToken = getAuthToken()
if (existingToken) {
try {
const userData = await getMe()
if (mounted) {
setUser(userData)
}
} catch (meError) {
// Token is invalid, clear it
if (mounted) {
setAuthToken(null)
}
}
}
} finally {
if (mounted) {
setIsLoading(false)
}
}
}
initAuth()
return () => {
mounted = false
}
}, [])
const login = (token: string, userData: User) => {
setAuthToken(token)
setUser(userData)
}
const logout = async () => {
try {
await apiLogout()
} catch (error) {
// Ignore logout API errors
console.warn('Logout API call failed:', error)
} finally {
setAuthToken(null)
setUser(null)
}
}
const updateUser = (userData: User) => {
setUser(userData)
}
return (
<AuthContext.Provider
value={{
user,
isAuthenticated,
isLoading,
login,
logout,
updateUser
}}
>
{children}
</AuthContext.Provider>
)
}

View File

@@ -0,0 +1,146 @@
import { useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router'
import { motion } from 'framer-motion'
import { Mail, Lock, Eye, EyeOff } from 'lucide-react'
import { login as apiLogin } from '../api/auth'
import { useAuth } from '../context/AuthContext'
import { Button } from '../components/ui/Button'
import { showToast } from '../utils/toast'
export function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const { login } = useAuth()
const navigate = useNavigate()
const location = useLocation()
// Get the page they were trying to visit, default to home
const from = location.state?.from?.pathname || '/'
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setIsLoading(true)
try {
const response = await apiLogin({ email, password })
login(response.access_token, response.user)
showToast.success(`Willkommen zurück, ${response.user.display_name}!`)
navigate(from, { replace: true })
} catch (err) {
setError('Ungültige E-Mail oder Passwort')
showToast.error('Anmeldung fehlgeschlagen')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<motion.div
className="w-full max-w-md"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
{/* Header */}
<div className="text-center mb-8">
<motion.div
className="text-6xl mb-4"
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
>
👨🍳
</motion.div>
<h1 className="font-display text-3xl text-espresso mb-2">
Willkommen zurück
</h1>
<p className="text-warm-grey">
Melde dich an, um deine Rezepte zu verwalten
</p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Email Field */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-espresso mb-2">
E-Mail
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full pl-10 pr-4 py-3 rounded-xl border border-sand focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors"
placeholder="deine@email.de"
required
/>
</div>
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-espresso mb-2">
Passwort
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-10 pr-12 py-3 rounded-xl border border-sand focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors"
placeholder="••••••••"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-warm-grey hover:text-espresso transition-colors"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
{/* Error Message */}
{error && (
<motion.div
className="text-berry-red text-sm text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
{error}
</motion.div>
)}
{/* Submit Button */}
<Button
type="submit"
disabled={isLoading}
className="w-full min-h-[44px] py-3"
>
{isLoading ? 'Wird angemeldet...' : 'Anmelden'}
</Button>
</form>
{/* Sign Up Link */}
<div className="text-center mt-6">
<p className="text-warm-grey">
Noch kein Account?{' '}
<Link to="/register" className="text-primary hover:underline font-medium">
Jetzt registrieren
</Link>
</p>
</div>
</motion.div>
</div>
)
}

View File

@@ -1,10 +1,17 @@
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { Link } from 'react-router'
import { fetchRecipes } from '../api/recipes'
import { fetchShopping } from '../api/shopping'
import { LogOut, Info, Heart, BookOpen, ShoppingCart } from 'lucide-react'
import { LogOut, Info, Heart, BookOpen, ShoppingCart, Settings, User } from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import { EmptyState } from '../components/ui/EmptyState'
import { Button } from '../components/ui/Button'
import { showToast } from '../utils/toast'
export function ProfilePage() {
const { user, isAuthenticated, logout, isLoading } = useAuth()
const { data: allRecipes } = useQuery({
queryKey: ['recipes', {}],
queryFn: () => fetchRecipes({}),
@@ -30,6 +37,41 @@ export function ProfilePage() {
{ icon: <ShoppingCart size={18} />, label: 'Einkauf', value: totalShoppingItems },
]
const handleLogout = async () => {
try {
await logout()
showToast.success('Erfolgreich abgemeldet')
} catch (error) {
console.error('Logout failed:', error)
showToast.error('Abmeldung fehlgeschlagen')
}
}
// Show login prompt if not authenticated
if (!isLoading && !isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center">
<EmptyState
icon="🔐"
title="Anmeldung erforderlich"
description="Melde dich an, um dein Profil zu verwalten"
/>
<div className="absolute bottom-32 left-1/2 transform -translate-x-1/2 space-y-3 w-full max-w-sm px-4">
<Link to="/login">
<Button className="w-full min-h-[44px]">
Anmelden
</Button>
</Link>
<Link to="/register">
<Button variant="ghost" className="w-full min-h-[44px]">
Registrieren
</Button>
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen">
{/* Header */}
@@ -39,10 +81,22 @@ export function ProfilePage() {
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
>
👤
{user?.avatar_url ? (
<img
src={user.avatar_url}
alt={user.display_name}
className="w-full h-full rounded-full object-cover"
/>
) : (
<User size={28} className="text-primary" />
)}
</motion.div>
<h1 className="font-display text-2xl text-espresso">Luna</h1>
<p className="text-sm text-warm-grey mt-1">Hobbyköchin & Rezeptsammlerin</p>
<h1 className="font-display text-2xl text-espresso">
{user?.display_name || 'Benutzer'}
</h1>
<p className="text-sm text-warm-grey mt-1">
{user?.email || 'Hobbyköchin & Rezeptsammlerin'}
</p>
</div>
{/* Stats */}
@@ -63,6 +117,34 @@ export function ProfilePage() {
</div>
</div>
{/* Profile Actions */}
<div className="px-4 pb-6">
<div className="bg-surface rounded-2xl p-4 shadow-sm space-y-3">
<div className="flex items-center gap-2 text-espresso font-medium text-sm mb-3">
<Settings size={16} className="text-primary" />
Profil verwalten
</div>
<button
disabled
title="Kommt bald"
className="w-full flex items-center justify-between p-3 rounded-xl bg-sand/30 text-warm-grey cursor-not-allowed opacity-60 transition-colors min-h-[44px]"
>
<span className="text-sm">Profil bearbeiten</span>
<span className="text-xs">Kommt bald</span>
</button>
<button
disabled
title="Kommt bald"
className="w-full flex items-center justify-between p-3 rounded-xl bg-sand/30 text-warm-grey cursor-not-allowed opacity-60 transition-colors min-h-[44px]"
>
<span className="text-sm">Passwort ändern</span>
<span className="text-xs">Kommt bald</span>
</button>
</div>
</div>
{/* App Info */}
<div className="px-4 pb-6">
<div className="bg-surface rounded-2xl p-4 shadow-sm space-y-3">
@@ -73,7 +155,7 @@ export function ProfilePage() {
<div className="text-sm text-warm-grey space-y-1">
<div className="flex justify-between">
<span>Version</span>
<span className="text-espresso">1.0</span>
<span className="text-espresso">2.0</span>
</div>
<div className="flex justify-between">
<span>Erstellt</span>
@@ -86,12 +168,11 @@ export function ProfilePage() {
{/* Logout Button */}
<div className="px-4 pb-8">
<button
disabled
title="Kommt in v2"
className="w-full flex items-center justify-center gap-2 bg-sand/50 text-warm-grey px-4 py-3 rounded-xl font-medium text-sm cursor-not-allowed opacity-60 min-h-[44px]"
onClick={handleLogout}
className="w-full flex items-center justify-center gap-2 bg-berry-red text-white px-4 py-3 rounded-xl font-medium text-sm hover:bg-berry-red/90 transition-colors min-h-[44px]"
>
<LogOut size={18} />
Abmelden kommt in v2
Abmelden
</button>
</div>
</div>

View File

@@ -0,0 +1,259 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router'
import { motion } from 'framer-motion'
import { Mail, Lock, User, Eye, EyeOff } from 'lucide-react'
import { register as apiRegister } from '../api/auth'
import { useAuth } from '../context/AuthContext'
import { Button } from '../components/ui/Button'
import { showToast } from '../utils/toast'
export function RegisterPage() {
const [formData, setFormData] = useState({
display_name: '',
email: '',
password: '',
confirmPassword: ''
})
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const { login } = useAuth()
const navigate = useNavigate()
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}
// Name validation
if (!formData.display_name.trim()) {
newErrors.display_name = 'Name ist erforderlich'
} else if (formData.display_name.trim().length < 2) {
newErrors.display_name = 'Name muss mindestens 2 Zeichen lang sein'
}
// Email validation
if (!formData.email) {
newErrors.email = 'E-Mail ist erforderlich'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Ungültige E-Mail-Adresse'
}
// Password validation
if (!formData.password) {
newErrors.password = 'Passwort ist erforderlich'
} else if (formData.password.length < 6) {
newErrors.password = 'Passwort muss mindestens 6 Zeichen lang sein'
}
// Confirm password validation
if (!formData.confirmPassword) {
newErrors.confirmPassword = 'Passwort bestätigen ist erforderlich'
} else if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwörter stimmen nicht überein'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleChange = (field: keyof typeof formData) => (
e: React.ChangeEvent<HTMLInputElement>
) => {
setFormData({ ...formData, [field]: e.target.value })
// Clear error for this field when user starts typing
if (errors[field]) {
setErrors({ ...errors, [field]: '' })
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) {
return
}
setIsLoading(true)
setErrors({})
try {
const response = await apiRegister({
display_name: formData.display_name.trim(),
email: formData.email,
password: formData.password
})
login(response.access_token, response.user)
showToast.success(`Willkommen bei Luna Rezepte, ${response.user.display_name}!`)
navigate('/', { replace: true })
} catch (err: any) {
if (err.message?.includes('409')) {
setErrors({ email: 'E-Mail-Adresse ist bereits registriert' })
showToast.error('E-Mail bereits registriert')
} else {
setErrors({ general: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' })
showToast.error('Registrierung fehlgeschlagen')
}
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<motion.div
className="w-full max-w-md"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
{/* Header */}
<div className="text-center mb-8">
<motion.div
className="text-6xl mb-4"
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
>
👨🍳
</motion.div>
<h1 className="font-display text-3xl text-espresso mb-2">
Account erstellen
</h1>
<p className="text-warm-grey">
Registriere dich und starte deine Rezeptsammlung
</p>
</div>
{/* Registration Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Name Field */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-espresso mb-2">
Name
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
<input
id="name"
type="text"
value={formData.display_name}
onChange={handleChange('display_name')}
className={`w-full pl-10 pr-4 py-3 rounded-xl border ${errors.display_name ? 'border-berry-red' : 'border-sand'} focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors`}
placeholder="Dein Name"
/>
</div>
{errors.display_name && (
<p className="text-berry-red text-sm mt-1">{errors.display_name}</p>
)}
</div>
{/* Email Field */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-espresso mb-2">
E-Mail
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
<input
id="email"
type="email"
value={formData.email}
onChange={handleChange('email')}
className={`w-full pl-10 pr-4 py-3 rounded-xl border ${errors.email ? 'border-berry-red' : 'border-sand'} focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors`}
placeholder="deine@email.de"
/>
</div>
{errors.email && (
<p className="text-berry-red text-sm mt-1">{errors.email}</p>
)}
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-espresso mb-2">
Passwort
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleChange('password')}
className={`w-full pl-10 pr-12 py-3 rounded-xl border ${errors.password ? 'border-berry-red' : 'border-sand'} focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-warm-grey hover:text-espresso transition-colors"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{errors.password && (
<p className="text-berry-red text-sm mt-1">{errors.password}</p>
)}
</div>
{/* Confirm Password Field */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-espresso mb-2">
Passwort bestätigen
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
<input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={handleChange('confirmPassword')}
className={`w-full pl-10 pr-12 py-3 rounded-xl border ${errors.confirmPassword ? 'border-berry-red' : 'border-sand'} focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-warm-grey hover:text-espresso transition-colors"
>
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{errors.confirmPassword && (
<p className="text-berry-red text-sm mt-1">{errors.confirmPassword}</p>
)}
</div>
{/* General Error Message */}
{errors.general && (
<motion.div
className="text-berry-red text-sm text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
{errors.general}
</motion.div>
)}
{/* Submit Button */}
<Button
type="submit"
disabled={isLoading}
className="w-full min-h-[44px] py-3"
>
{isLoading ? 'Account wird erstellt...' : 'Registrieren'}
</Button>
</form>
{/* Login Link */}
<div className="text-center mt-6">
<p className="text-warm-grey">
Schon einen Account?{' '}
<Link to="/login" className="text-primary hover:underline font-medium">
Jetzt anmelden
</Link>
</p>
</div>
</motion.div>
</div>
)
}

View File

@@ -7,6 +7,7 @@ import {
toggleCheck,
deleteItem,
deleteChecked,
deleteAll,
} from '../api/shopping'
import type { ShoppingGroup, ShoppingItem } from '../api/shopping'
import { EmptyState } from '../components/ui/EmptyState'
@@ -36,6 +37,13 @@ export function ShoppingPage() {
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
})
const deleteAllMutation = useMutation({
mutationFn: deleteAll,
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
})
const [showClearConfirm, setShowClearConfirm] = useState(false)
const addMutation = useMutation({
mutationFn: addCustomItem,
onSuccess: () => {
@@ -121,12 +129,48 @@ export function ShoppingPage() {
className="p-2 text-warm-grey hover:text-berry-red transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
title="Erledigte löschen"
>
<Trash2 size={20} />
<Trash2 size={18} />
</button>
)}
{totalItems > 0 && (
<button
onClick={() => setShowClearConfirm(true)}
className="p-2 text-warm-grey hover:text-berry-red transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
title="Alles löschen"
>
<Trash2 size={20} className="text-berry-red/70" />
</button>
)}
</div>
</div>
{/* Clear All Confirm Dialog */}
{showClearConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm px-6">
<div className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl">
<h3 className="font-display text-lg text-espresso mb-2">Einkaufsliste leeren?</h3>
<p className="text-warm-grey text-sm mb-5">Alle Artikel werden unwiderruflich gelöscht.</p>
<div className="flex gap-3">
<button
onClick={() => setShowClearConfirm(false)}
className="flex-1 py-3 rounded-xl border border-sand text-espresso font-medium min-h-[44px]"
>
Abbrechen
</button>
<button
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
</button>
</div>
</div>
</div>
)}
{/* Pull indicator */}
{pulling && (
<div className="text-center text-warm-grey text-sm py-2"> Loslassen zum Aktualisieren</div>
@@ -230,9 +274,11 @@ function ShoppingItemRow({
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
@@ -243,17 +289,19 @@ function ShoppingItemRow({
const dx = e.touches[0].clientX - touchStartX.current
if (dx < -10) {
swiping.current = true
setSwipeX(Math.max(dx, -80))
setSwipeX(Math.max(dx, -160))
} else {
setSwipeX(0)
}
}
const handleTouchEnd = () => {
if (swipeX < -60) {
onDelete()
if (pastThreshold) {
setSwipeX(-300)
setTimeout(() => onDelete(), 200)
} else {
setSwipeX(0)
}
setSwipeX(0)
swiping.current = false
}
@@ -262,12 +310,14 @@ function ShoppingItemRow({
return (
<li className="relative overflow-hidden">
{/* Delete background */}
<div className="absolute inset-y-0 right-0 w-20 bg-berry-red flex items-center justify-center">
<X size={18} className="text-white" />
<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] transition-transform"
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}

View File

@@ -0,0 +1,53 @@
import toast from 'react-hot-toast'
export const showToast = {
success: (message: string) => {
toast.success(message, {
duration: 4000,
style: {
background: '#C4737E',
color: 'white',
borderRadius: '12px',
fontSize: '14px',
fontWeight: '500',
},
iconTheme: {
primary: '#C4737E',
secondary: 'white',
},
})
},
error: (message: string) => {
toast.error(message, {
duration: 5000,
style: {
background: '#C94C4C',
color: 'white',
borderRadius: '12px',
fontSize: '14px',
fontWeight: '500',
},
iconTheme: {
primary: '#C94C4C',
secondary: 'white',
},
})
},
loading: (message: string) => {
return toast.loading(message, {
style: {
background: '#7A6E65',
color: 'white',
borderRadius: '12px',
fontSize: '14px',
fontWeight: '500',
},
})
},
dismiss: (toastId: string) => {
toast.dismiss(toastId)
}
}