feat: Add EU cookie banner and user settings page
Cookie Banner: - GDPR-compliant consent banner - Shows on first visit, stores consent in localStorage - Two options: Accept All / Only Essential - Dark theme with smooth animations Settings Page (/settings): - Profile form: display name editing - Password change with validation - Cookie settings info section - Privacy links and account info New files: - src/hooks/useCookieConsent.ts - src/components/CookieBanner.tsx - src/components/ProfileForm.tsx - src/components/PasswordChangeForm.tsx - src/services/profile-actions.ts - src/app/settings/page.tsx
This commit is contained in:
90
src/components/CookieBanner.tsx
Normal file
90
src/components/CookieBanner.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Cookie, Shield, X } from 'lucide-react';
|
||||
import { useCookieConsent } from '@/hooks/useCookieConsent';
|
||||
import Link from 'next/link';
|
||||
|
||||
/**
|
||||
* EU GDPR-compliant cookie consent banner
|
||||
* Shows on first visit, stores consent in localStorage
|
||||
*/
|
||||
export default function CookieBanner() {
|
||||
const { hasConsent, isLoading, acceptAll, acceptEssential } = useCookieConsent();
|
||||
|
||||
// Don't render while loading or if consent already given
|
||||
if (isLoading || hasConsent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed bottom-0 left-0 right-0 z-[100] p-4 md:p-6"
|
||||
>
|
||||
<div className="max-w-4xl mx-auto bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-orange-500/10 rounded-xl">
|
||||
<Cookie size={24} className="text-orange-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white">Cookie-Einstellungen</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-zinc-400 text-sm leading-relaxed">
|
||||
Wir verwenden Cookies, um die Funktionalität unserer App zu gewährleisten.
|
||||
Wir setzen <span className="text-white font-medium">keine Tracking- oder Marketing-Cookies</span> ein.
|
||||
</p>
|
||||
|
||||
{/* Cookie types */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Shield size={14} className="text-green-500" />
|
||||
<span className="text-zinc-300">
|
||||
<span className="font-medium text-white">Notwendig:</span> Authentifizierung & Sicherheit
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Shield size={14} className="text-blue-500" />
|
||||
<span className="text-zinc-300">
|
||||
<span className="font-medium text-white">Funktional:</span> Spracheinstellungen & UI-Präferenzen
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-4 bg-zinc-950/50 border-t border-zinc-800 flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={acceptEssential}
|
||||
className="flex-1 px-4 py-3 text-sm font-bold text-zinc-400 hover:text-white border border-zinc-700 hover:border-zinc-600 rounded-xl transition-all"
|
||||
>
|
||||
Nur Notwendige
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptAll}
|
||||
className="flex-1 px-4 py-3 text-sm font-bold text-zinc-900 bg-orange-500 hover:bg-orange-400 rounded-xl transition-all"
|
||||
>
|
||||
Alle akzeptieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Privacy link */}
|
||||
<div className="px-4 pb-4 text-center">
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-xs text-zinc-500 hover:text-zinc-400 underline"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
155
src/components/PasswordChangeForm.tsx
Normal file
155
src/components/PasswordChangeForm.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Lock, Eye, EyeOff, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { changePassword } from '@/services/profile-actions';
|
||||
|
||||
export default function PasswordChangeForm() {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setStatus('idle');
|
||||
setError(null);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setStatus('error');
|
||||
setError('Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setStatus('error');
|
||||
setError('Passwort muss mindestens 6 Zeichen lang sein');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set('new_password', newPassword);
|
||||
formData.set('confirm_password', confirmPassword);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await changePassword(formData);
|
||||
if (result.success) {
|
||||
setStatus('success');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setTimeout(() => setStatus('idle'), 3000);
|
||||
} else {
|
||||
setStatus('error');
|
||||
setError(result.error || 'Fehler beim Ändern');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<h2 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
|
||||
<Lock size={20} className="text-orange-500" />
|
||||
Passwort ändern
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* New Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||
Neues Passwort
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-3 pr-12 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||
Passwort bestätigen
|
||||
</label>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password match indicator */}
|
||||
{confirmPassword && (
|
||||
<div className={`mt-2 text-xs flex items-center gap-1 ${newPassword === confirmPassword ? 'text-green-500' : 'text-red-500'
|
||||
}`}>
|
||||
{newPassword === confirmPassword ? (
|
||||
<>
|
||||
<CheckCircle size={12} />
|
||||
Passwörter stimmen überein
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle size={12} />
|
||||
Passwörter stimmen nicht überein
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status messages */}
|
||||
{status === 'success' && (
|
||||
<div className="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-xl flex items-center gap-2 text-green-500 text-sm">
|
||||
<CheckCircle size={16} />
|
||||
Passwort erfolgreich geändert!
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-2 text-red-500 text-sm">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || !newPassword || !confirmPassword}
|
||||
className="mt-6 w-full px-4 py-3 bg-orange-500 hover:bg-orange-400 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-900 font-bold rounded-xl flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
Ändern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock size={18} />
|
||||
Passwort ändern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</motion.form>
|
||||
);
|
||||
}
|
||||
118
src/components/ProfileForm.tsx
Normal file
118
src/components/ProfileForm.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { User, Mail, Save, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { updateProfile } from '@/services/profile-actions';
|
||||
|
||||
interface ProfileFormProps {
|
||||
initialData: {
|
||||
email?: string;
|
||||
display_name?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProfileForm({ initialData }: ProfileFormProps) {
|
||||
const [displayName, setDisplayName] = useState(initialData.display_name || '');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setStatus('idle');
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set('display_name', displayName);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateProfile(formData);
|
||||
if (result.success) {
|
||||
setStatus('success');
|
||||
setTimeout(() => setStatus('idle'), 3000);
|
||||
} else {
|
||||
setStatus('error');
|
||||
setError(result.error || 'Fehler beim Speichern');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<h2 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
|
||||
<User size={20} className="text-orange-500" />
|
||||
Profil
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Email (read-only) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||
<Mail size={14} className="inline mr-2" />
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={initialData.email || ''}
|
||||
disabled
|
||||
className="w-full px-4 py-3 bg-zinc-800/50 border border-zinc-700 rounded-xl text-zinc-500 cursor-not-allowed"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-zinc-500">E-Mail kann nicht geändert werden</p>
|
||||
</div>
|
||||
|
||||
{/* Display Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||
Anzeigename
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Dein Name"
|
||||
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status messages */}
|
||||
{status === 'success' && (
|
||||
<div className="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-xl flex items-center gap-2 text-green-500 text-sm">
|
||||
<CheckCircle size={16} />
|
||||
Profil gespeichert!
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-2 text-red-500 text-sm">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="mt-6 w-full px-4 py-3 bg-orange-500 hover:bg-orange-400 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-900 font-bold rounded-xl flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
Speichern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={18} />
|
||||
Speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</motion.form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user