SportBuddy/apps/frontend/src/app/profile/edit/page.tsx
Jozef Kovalčín 9a6fd12b0f feat: Implementácia US-002 - Používateľský profil & Dashboard
- Backend API: GET/PUT /api/profile s podporou image upload
- Backend API: GET /api/activities/my pre používateľove aktivity
- Frontend: Profil stránka (/profile) s zobrazením dát
- Frontend: Editácia profilu (/profile/edit) s upload fotky
- Dashboard: Reálne štatistiky a zoznam aktivít
- Navigation: Pridaný link na profil
- Validácia: Formuláre, file upload (max 2MB)
- Responzívny dizajn pre všetky obrazovky
2025-10-30 20:49:37 +01:00

397 lines
14 KiB
TypeScript

'use client';
import { useSession } from '@/lib/auth-client';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
interface ProfileData {
id: string;
name: string;
email: string;
image: string | null;
profile: {
bio: string | null;
phone: string | null;
city: string | null;
skillLevel: string;
favoriteSports: string[];
} | null;
}
const SPORT_OPTIONS = [
{ value: 'FOOTBALL', label: 'Futbal' },
{ value: 'BASKETBALL', label: 'Basketbal' },
{ value: 'TENNIS', label: 'Tenis' },
{ value: 'VOLLEYBALL', label: 'Volejbal' },
{ value: 'BADMINTON', label: 'Bedminton' },
{ value: 'TABLE_TENNIS', label: 'Stolný tenis' },
{ value: 'RUNNING', label: 'Beh' },
{ value: 'CYCLING', label: 'Cyklistika' },
{ value: 'SWIMMING', label: 'Plávanie' },
{ value: 'GYM', label: 'Fitnes' },
{ value: 'OTHER', label: 'Iné' },
];
const SKILL_OPTIONS = [
{ value: 'BEGINNER', label: 'Začiatočník' },
{ value: 'INTERMEDIATE', label: 'Stredne pokročilý' },
{ value: 'ADVANCED', label: 'Pokročilý' },
{ value: 'EXPERT', label: 'Expert' },
];
export default function ProfileEditPage() {
const { data: session, isPending } = useSession();
const router = useRouter();
const [profile, setProfile] = useState<ProfileData | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [formData, setFormData] = useState({
name: '',
bio: '',
phone: '',
city: '',
skillLevel: 'BEGINNER',
favoriteSports: [] as string[],
image: '',
});
const [imagePreview, setImagePreview] = useState<string | null>(null);
useEffect(() => {
if (!isPending && !session) {
router.push('/auth/signin');
}
}, [session, isPending, router]);
useEffect(() => {
if (session) {
fetchProfile();
}
}, [session]);
const fetchProfile = async () => {
try {
setLoading(true);
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/profile`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error('Failed to fetch profile');
}
const data = await response.json();
setProfile(data);
setFormData({
name: data.name || '',
bio: data.profile?.bio || '',
phone: data.profile?.phone || '',
city: data.profile?.city || '',
skillLevel: data.profile?.skillLevel || 'BEGINNER',
favoriteSports: data.profile?.favoriteSports || [],
image: data.image || '',
});
setImagePreview(data.image);
} catch (err) {
console.error('Error fetching profile:', err);
setError('Nepodarilo sa načítať profil');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(false);
setSaving(true);
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/profile`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(formData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update profile');
}
setSuccess(true);
setTimeout(() => {
router.push('/profile');
}, 1500);
} catch (err: any) {
console.error('Error updating profile:', err);
setError(err.message || 'Nepodarilo sa aktualizovať profil');
} finally {
setSaving(false);
}
};
const handleSportToggle = (sport: string) => {
setFormData((prev) => ({
...prev,
favoriteSports: prev.favoriteSports.includes(sport)
? prev.favoriteSports.filter((s) => s !== sport)
: [...prev.favoriteSports, sport],
}));
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
setError('Obrázok je príliš veľký. Maximum je 2MB.');
return;
}
if (!file.type.startsWith('image/')) {
setError('Môžete nahrať iba obrázky.');
return;
}
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
setFormData((prev) => ({ ...prev, image: base64String }));
setImagePreview(base64String);
};
reader.readAsDataURL(file);
};
const handleRemoveImage = () => {
setFormData((prev) => ({ ...prev, image: '' }));
setImagePreview(null);
};
if (isPending || loading) {
return (
<main className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-[color:var(--fluent-text-secondary)]">Načítavam profil...</p>
</div>
</main>
);
}
if (!session || !profile) {
return null;
}
return (
<main className="min-h-screen" style={{ backgroundColor: 'var(--fluent-bg)' }}>
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<h1 className="text-3xl font-bold text-[color:var(--fluent-text)] mb-2">
Upraviť profil
</h1>
<p className="text-[color:var(--fluent-text-secondary)]">
Aktualizujte svoje informácie a športové preferencie
</p>
</div>
<Card>
<CardContent className="p-8">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-[color:var(--fluent-text)] mb-3">
Profilová fotka
</label>
<div className="flex items-center gap-6">
<div className="flex-shrink-0">
{imagePreview ? (
<img
src={imagePreview}
alt="Náhľad"
className="w-24 h-24 rounded-full object-cover"
style={{ boxShadow: 'var(--shadow-lg)' }}
/>
) : (
<div
className="w-24 h-24 rounded-full gradient-primary flex items-center justify-center"
style={{ boxShadow: 'var(--shadow-lg)' }}
>
<span className="text-3xl font-bold text-white">
{formData.name.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
<div className="flex-1">
<input
type="file"
id="imageUpload"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
<div className="flex gap-3">
<label
htmlFor="imageUpload"
className="px-4 py-2 bg-blue-500 text-white rounded-lg font-medium cursor-pointer hover:bg-blue-600 transition-colors"
>
Nahrať obrázok
</label>
{imagePreview && (
<button
type="button"
onClick={handleRemoveImage}
className="px-4 py-2 bg-red-500 text-white rounded-lg font-medium hover:bg-red-600 transition-colors"
>
Odstrániť
</button>
)}
</div>
<p className="text-xs text-[color:var(--fluent-text-secondary)] mt-2">
JPG, PNG alebo GIF. Maximum 2MB.
</p>
</div>
</div>
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-[color:var(--fluent-text)] mb-2">
Meno <span className="text-red-500">*</span>
</label>
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
placeholder="Vaše meno"
/>
</div>
<div>
<label htmlFor="bio" className="block text-sm font-medium text-[color:var(--fluent-text)] mb-2">
O mne
</label>
<textarea
id="bio"
value={formData.bio}
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
placeholder="Niečo o vás..."
rows={4}
className="w-full px-4 py-3 rounded-lg border-2 border-[color:var(--fluent-border)] bg-[color:var(--fluent-surface)] text-[color:var(--fluent-text)] focus:border-blue-500 focus:outline-none transition-colors"
/>
</div>
<div>
<label htmlFor="city" className="block text-sm font-medium text-[color:var(--fluent-text)] mb-2">
Mesto
</label>
<Input
id="city"
type="text"
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
placeholder="Bratislava"
/>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-[color:var(--fluent-text)] mb-2">
Telefón
</label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
placeholder="+421 XXX XXX XXX"
/>
</div>
<div>
<label htmlFor="skillLevel" className="block text-sm font-medium text-[color:var(--fluent-text)] mb-2">
Športová úroveň
</label>
<select
id="skillLevel"
value={formData.skillLevel}
onChange={(e) => setFormData({ ...formData, skillLevel: e.target.value })}
className="w-full px-4 py-3 rounded-lg border-2 border-[color:var(--fluent-border)] bg-[color:var(--fluent-surface)] text-[color:var(--fluent-text)] focus:border-blue-500 focus:outline-none transition-colors"
>
{SKILL_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-[color:var(--fluent-text)] mb-3">
Obľúbené športy
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{SPORT_OPTIONS.map((sport) => (
<button
key={sport.value}
type="button"
onClick={() => handleSportToggle(sport.value)}
className={`px-4 py-3 rounded-lg border-2 transition-all ${
formData.favoriteSports.includes(sport.value)
? 'border-blue-500 bg-blue-500 text-white font-medium'
: 'border-[color:var(--fluent-border)] bg-[color:var(--fluent-surface)] text-[color:var(--fluent-text)] hover:border-blue-300'
}`}
>
{sport.label}
</button>
))}
</div>
</div>
{error && (
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800">
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{success && (
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800">
<p className="text-green-600 dark:text-green-400">
Profil bol úspešne aktualizovaný! Presmerovávam...
</p>
</div>
)}
<div className="flex gap-4 pt-4">
<Button
type="submit"
disabled={saving}
className="flex-1"
>
{saving ? 'Ukladám...' : 'Uložiť zmeny'}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => router.push('/profile')}
disabled={saving}
>
Zrušiť
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</main>
);
}