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:
2025-12-26 21:30:00 +01:00
parent 9c5f538efb
commit 6c37481d17
8 changed files with 692 additions and 1 deletions

View 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>
);
}