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
This commit is contained in:
Jozef Kovalčín 2025-10-30 20:43:28 +01:00
parent 84cf61586a
commit 9a6fd12b0f
7 changed files with 1222 additions and 59 deletions

View File

@ -34,43 +34,43 @@ aby som mohol používať aplikáciu
## US-002: Používateľský profil & Dashboard
**Status:** 📋 PLANNED
**Status:** ✅ HOTOVÉ
Ako používateľ
chcem vidieť a upraviť môj profil a dashboard s mojimi aktivitami
aby som mohol prezentovať svoje športové záujmy a mať prehľad o mojich udalostiach
**Vývojár:** Kamil Berecký
**Vývojár:** Jozef Kovalčín
### Tasky:
#### Profil sekcia
- ⏸️ Profil stránka (/profile) - **NEIMPLEMENTOVANÉ**
- ⏸️ Zobrazenie: meno, email, mesto, bio, obľúbené športy
- ⏸️ Formulár na editáciu profilu (/profile/edit)
- ⏸️ Upload profilovej fotky
- ⏸️ API: GET /api/profile
- ⏸️ API: PUT /api/profile
- ⏸️ Validácia formulára
- ⏸️ Responzívny dizajn
- ✅ Profil stránka (/profile)
- Zobrazenie: meno, email, mesto, bio, obľúbené športy
- Formulár na editáciu profilu (/profile/edit)
- Upload profilovej fotky
- API: GET /api/profile
- API: PUT /api/profile
- Validácia formulára
- Responzívny dizajn
#### Dashboard sekcia
- ⏸️ Dashboard stránka (/dashboard) - **EXISTUJE ale je prázdny**
- ⏸️ API: GET /api/activities/my (filtrovanie podľa userId)
- ⏸️ Dve sekcie: "Moje aktivity" (vytvorené) a "Prihlásený na" (joined)
- ⏸️ Používanie Activity card komponentu
- ⏸️ Loading state
- ⏸️ Empty states
- ⏸️ Quick actions: "Vytvoriť aktivitu", "Hľadať aktivity"
- ⏸️ Štatistiky: počet aktivít, počet prihlásení - **Základné karty existujú**
- ⏸️ Responzívny layout
- ✅ Dashboard stránka (/dashboard)
- API: GET /api/activities/my (filtrovanie podľa userId)
- Dve sekcie: "Moje aktivity" (vytvorené) a "Prihlásený na" (joined)
- Používanie Activity card komponentu
- Loading state
- Empty states
- Quick actions: "Vytvoriť aktivitu", "Hľadať aktivity"
- Štatistiky: počet aktivít, počet prihlásení
- Responzívny layout
### Výsledné funkcie:
- ⏸️ Zobrazenie profilu
- ⏸️ Editácia profilu
- ⏸️ Upload fotky
- ⏸️ Dashboard so zoznamom - **Dashboard existuje, ale nezobrazuje zoznam aktivít**
- ⏸️ Filtrovanie funguje
- ⏸️ Štatistiky sa zobrazujú - **Iba placeholder štatistiky (0, 0, -)**
- Zobrazenie profilu
- Editácia profilu
- Upload fotky
- ✅ Dashboard so zoznamom
- Filtrovanie funguje
- ✅ Štatistiky sa zobrazujú
**Poznámka:** Profil model existuje v databáze (User + Profile), ale žiadne UI stránky nie sú implementované. Dashboard stránka existuje s navigáciou a základnou štruktúrou (3 štatistické karty), ale neobsahuje žiadne reálne dáta ani zoznam aktivít.

View File

@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
// GET /api/activities/my - Get current user's activities (created and joined)
export async function GET(req: NextRequest) {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = session.user.id;
// Get activities created by user
const createdActivities = await prisma.activity.findMany({
where: {
organizerId: userId,
},
include: {
venue: true,
organizer: {
select: {
id: true,
name: true,
image: true,
},
},
participations: {
include: {
user: {
select: {
id: true,
name: true,
image: true,
},
},
},
},
},
orderBy: {
date: 'asc',
},
});
// Get activities user joined
const joinedActivities = await prisma.activity.findMany({
where: {
participations: {
some: {
userId: userId,
},
},
organizerId: {
not: userId, // Exclude activities organized by user (already in createdActivities)
},
},
include: {
venue: true,
organizer: {
select: {
id: true,
name: true,
image: true,
},
},
participations: {
include: {
user: {
select: {
id: true,
name: true,
image: true,
},
},
},
},
},
orderBy: {
date: 'asc',
},
});
return NextResponse.json({
created: createdActivities,
joined: joinedActivities,
stats: {
totalCreated: createdActivities.length,
totalJoined: joinedActivities.length,
upcomingCreated: createdActivities.filter((a: any) => new Date(a.date) > new Date()).length,
upcomingJoined: joinedActivities.filter((a: any) => new Date(a.date) > new Date()).length,
},
});
} catch (error) {
console.error('Error fetching user activities:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,155 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
// GET /api/profile - Get current user's profile
export async function GET(req: NextRequest) {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: {
profile: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// If profile doesn't exist, create it
if (!user.profile) {
const newProfile = await prisma.profile.create({
data: {
userId: user.id,
},
});
user.profile = newProfile;
}
return NextResponse.json({
id: user.id,
name: user.name,
email: user.email,
image: user.image,
profile: user.profile,
});
} catch (error) {
console.error('Error fetching profile:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// PUT /api/profile - Update current user's profile
export async function PUT(req: NextRequest) {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const {
name,
bio,
phone,
city,
skillLevel,
favoriteSports,
image,
} = body;
// Validate skillLevel if provided
if (skillLevel && !['BEGINNER', 'INTERMEDIATE', 'ADVANCED', 'EXPERT'].includes(skillLevel)) {
return NextResponse.json(
{ error: 'Invalid skill level' },
{ status: 400 }
);
}
// Validate favoriteSports if provided
const validSportTypes = [
'FOOTBALL', 'BASKETBALL', 'TENNIS', 'VOLLEYBALL', 'BADMINTON',
'TABLE_TENNIS', 'RUNNING', 'CYCLING', 'SWIMMING', 'GYM', 'OTHER'
];
if (favoriteSports && !Array.isArray(favoriteSports)) {
return NextResponse.json(
{ error: 'Favorite sports must be an array' },
{ status: 400 }
);
}
if (favoriteSports && !favoriteSports.every((sport: string) => validSportTypes.includes(sport))) {
return NextResponse.json(
{ error: 'Invalid sport type' },
{ status: 400 }
);
}
// Update user name and image if provided
const updateUserData: any = {};
if (name !== undefined) {
updateUserData.name = name;
}
if (image !== undefined) {
updateUserData.image = image;
}
if (Object.keys(updateUserData).length > 0) {
await prisma.user.update({
where: { id: session.user.id },
data: updateUserData,
});
}
// Update or create profile
const updateProfileData: any = {};
if (bio !== undefined) updateProfileData.bio = bio;
if (phone !== undefined) updateProfileData.phone = phone;
if (city !== undefined) updateProfileData.city = city;
if (skillLevel !== undefined) updateProfileData.skillLevel = skillLevel;
if (favoriteSports !== undefined) updateProfileData.favoriteSports = favoriteSports;
const profile = await prisma.profile.upsert({
where: { userId: session.user.id },
update: updateProfileData,
create: {
userId: session.user.id,
...updateProfileData,
},
});
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: {
profile: true,
},
});
return NextResponse.json({
id: user!.id,
name: user!.name,
email: user!.email,
image: user!.image,
profile: user!.profile,
});
} catch (error) {
console.error('Error updating profile:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -2,14 +2,72 @@
import { useSession } from '@/lib/auth-client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
interface Activity {
id: string;
title: string;
description: string | null;
sportType: string;
skillLevel: string;
date: string;
duration: number;
maxParticipants: number;
currentParticipants: number;
status: string;
venue: {
id: string;
name: string;
city: string;
address: string;
};
organizer: {
id: string;
name: string;
image: string | null;
};
participations: Array<{
user: {
id: string;
name: string;
image: string | null;
};
}>;
}
interface UserActivities {
created: Activity[];
joined: Activity[];
stats: {
totalCreated: number;
totalJoined: number;
upcomingCreated: number;
upcomingJoined: number;
};
}
const SPORT_LABELS: Record<string, string> = {
FOOTBALL: 'Futbal',
BASKETBALL: 'Basketbal',
TENNIS: 'Tenis',
VOLLEYBALL: 'Volejbal',
BADMINTON: 'Bedminton',
TABLE_TENNIS: 'Stolný tenis',
RUNNING: 'Beh',
CYCLING: 'Cyklistika',
SWIMMING: 'Plávanie',
GYM: 'Fitnes',
OTHER: 'Iné',
};
export default function DashboardPage() {
const { data: session, isPending } = useSession();
const router = useRouter();
const [activities, setActivities] = useState<UserActivities | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!isPending && !session) {
@ -17,7 +75,42 @@ export default function DashboardPage() {
}
}, [session, isPending, router]);
if (isPending) {
useEffect(() => {
if (session) {
fetchActivities();
}
}, [session]);
const fetchActivities = async () => {
try {
setLoading(true);
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/activities/my`, {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
setActivities(data);
}
} catch (error) {
console.error('Error fetching activities:', error);
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('sk-SK', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (isPending || loading) {
return (
<main className="min-h-screen flex items-center justify-center">
<div className="text-center">
@ -64,8 +157,12 @@ export default function DashboardPage() {
<CardTitle>Moje aktivity</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold text-[color:var(--fluent-text)]">0</p>
<p className="text-sm text-[color:var(--fluent-text-secondary)] mt-1">Vytvorené aktivity</p>
<p className="text-3xl font-bold text-[color:var(--fluent-text)]">
{activities?.stats.totalCreated || 0}
</p>
<p className="text-sm text-[color:var(--fluent-text-secondary)] mt-1">
Vytvorené aktivity ({activities?.stats.upcomingCreated || 0} nadchádzajúcich)
</p>
</CardContent>
</Card>
@ -79,8 +176,12 @@ export default function DashboardPage() {
<CardTitle>Prihlásený na</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold text-[color:var(--fluent-text)]">0</p>
<p className="text-sm text-[color:var(--fluent-text-secondary)] mt-1">Nadchádzajúcich aktivít</p>
<p className="text-3xl font-bold text-[color:var(--fluent-text)]">
{activities?.stats.totalJoined || 0}
</p>
<p className="text-sm text-[color:var(--fluent-text-secondary)] mt-1">
Aktivít ({activities?.stats.upcomingJoined || 0} nadchádzajúcich)
</p>
</CardContent>
</Card>
@ -91,11 +192,15 @@ export default function DashboardPage() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</div>
<CardTitle>Hodnotenie</CardTitle>
<CardTitle>Celkom aktivít</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold text-[color:var(--fluent-text)]">-</p>
<p className="text-sm text-[color:var(--fluent-text-secondary)] mt-1">Zatiaľ žiadne hodnotenia</p>
<p className="text-3xl font-bold text-[color:var(--fluent-text)]">
{(activities?.stats.totalCreated || 0) + (activities?.stats.totalJoined || 0)}
</p>
<p className="text-sm text-[color:var(--fluent-text-secondary)] mt-1">
Účastí na športových aktivitách
</p>
</CardContent>
</Card>
</div>
@ -148,30 +253,137 @@ export default function DashboardPage() {
</div>
</div>
{/* Empty State */}
<Card>
<CardContent className="text-center py-16">
<div className="w-20 h-20 bg-[color:var(--fluent-surface-secondary)] rounded-full mx-auto mb-6 flex items-center justify-center" style={{ boxShadow: 'var(--shadow-sm)' }}>
<svg className="w-10 h-10 text-[color:var(--fluent-text-tertiary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
<h3 className="text-xl font-bold text-[color:var(--fluent-text)] mb-2">
Zatiaľ žiadne aktivity
</h3>
<p className="text-[color:var(--fluent-text-secondary)] mb-6 max-w-md mx-auto">
Začnite vytvorením novej aktivity alebo sa pripojte k existujúcim aktivitám vo vašom okolí.
</p>
<div className="flex gap-4 justify-center">
<Link href="/activities/create">
<Button size="lg">Vytvoriť aktivitu</Button>
</Link>
<Link href="/activities">
<Button variant="secondary" size="lg">Prehliadať aktivity</Button>
</Link>
</div>
</CardContent>
</Card>
{/* Activities Lists */}
{activities && (activities.created.length > 0 || activities.joined.length > 0) ? (
<div className="space-y-8">
{/* Created Activities */}
{activities.created.length > 0 && (
<div>
<h2 className="text-2xl font-bold text-[color:var(--fluent-text)] mb-6">
Moje vytvorené aktivity
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{activities.created.map((activity) => (
<Link key={activity.id} href={`/activities/${activity.id}`}>
<Card hover className="h-full cursor-pointer">
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<span className="px-3 py-1 rounded-full text-sm font-medium bg-blue-500 text-white">
{SPORT_LABELS[activity.sportType] || activity.sportType}
</span>
<span className="text-xs text-[color:var(--fluent-text-secondary)]">
Organizátor
</span>
</div>
<h3 className="text-lg font-bold text-[color:var(--fluent-text)] mb-2">
{activity.title}
</h3>
<div className="space-y-2 text-sm text-[color:var(--fluent-text-secondary)]">
<div className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{formatDate(activity.date)}</span>
</div>
<div className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>{activity.venue.name}, {activity.venue.city}</span>
</div>
<div className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<span>{activity.currentParticipants}/{activity.maxParticipants} účastníkov</span>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)}
{/* Joined Activities */}
{activities.joined.length > 0 && (
<div>
<h2 className="text-2xl font-bold text-[color:var(--fluent-text)] mb-6">
Aktivity, na ktoré som prihlásený
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{activities.joined.map((activity) => (
<Link key={activity.id} href={`/activities/${activity.id}`}>
<Card hover className="h-full cursor-pointer">
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<span className="px-3 py-1 rounded-full text-sm font-medium bg-green-500 text-white">
{SPORT_LABELS[activity.sportType] || activity.sportType}
</span>
<span className="text-xs text-[color:var(--fluent-text-secondary)]">
Účastník
</span>
</div>
<h3 className="text-lg font-bold text-[color:var(--fluent-text)] mb-2">
{activity.title}
</h3>
<div className="space-y-2 text-sm text-[color:var(--fluent-text-secondary)]">
<div className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{formatDate(activity.date)}</span>
</div>
<div className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>{activity.venue.name}, {activity.venue.city}</span>
</div>
<div className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>Organizuje: {activity.organizer.name}</span>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)}
</div>
) : (
/* Empty State */
<Card>
<CardContent className="text-center py-16">
<div className="w-20 h-20 bg-[color:var(--fluent-surface-secondary)] rounded-full mx-auto mb-6 flex items-center justify-center" style={{ boxShadow: 'var(--shadow-sm)' }}>
<svg className="w-10 h-10 text-[color:var(--fluent-text-tertiary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
<h3 className="text-xl font-bold text-[color:var(--fluent-text)] mb-2">
Zatiaľ žiadne aktivity
</h3>
<p className="text-[color:var(--fluent-text-secondary)] mb-6 max-w-md mx-auto">
Začnite vytvorením novej aktivity alebo sa pripojte k existujúcim aktivitám vo vašom okolí.
</p>
<div className="flex gap-4 justify-center">
<Link href="/activities/create">
<Button size="lg">Vytvoriť aktivitu</Button>
</Link>
<Link href="/activities">
<Button variant="secondary" size="lg">Prehliadať aktivity</Button>
</Link>
</div>
</CardContent>
</Card>
)}
</div>
</main>
);

View File

@ -0,0 +1,396 @@
'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>
);
}

View File

@ -0,0 +1,265 @@
'use client';
import { useSession } from '@/lib/auth-client';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
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_LABELS: Record<string, string> = {
FOOTBALL: 'Futbal',
BASKETBALL: 'Basketbal',
TENNIS: 'Tenis',
VOLLEYBALL: 'Volejbal',
BADMINTON: 'Bedminton',
TABLE_TENNIS: 'Stolný tenis',
RUNNING: 'Beh',
CYCLING: 'Cyklistika',
SWIMMING: 'Plávanie',
GYM: 'Fitnes',
OTHER: 'Iné',
};
const SKILL_LABELS: Record<string, string> = {
BEGINNER: 'Začiatočník',
INTERMEDIATE: 'Stredne pokročilý',
ADVANCED: 'Pokročilý',
EXPERT: 'Expert',
};
export default function ProfilePage() {
const { data: session, isPending } = useSession();
const router = useRouter();
const [profile, setProfile] = useState<ProfileData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = 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);
} catch (err) {
console.error('Error fetching profile:', err);
setError('Nepodarilo sa načítať profil');
} finally {
setLoading(false);
}
};
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;
}
if (error) {
return (
<main className="min-h-screen flex items-center justify-center">
<Card>
<CardContent className="text-center py-12">
<p className="text-red-500 mb-4">{error}</p>
<Button onClick={fetchProfile}>Skúsiť znova</Button>
</CardContent>
</Card>
</main>
);
}
return (
<main className="min-h-screen" style={{ backgroundColor: 'var(--fluent-bg)' }}>
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-[color:var(--fluent-text)]">
Môj profil
</h1>
<Link href="/profile/edit">
<Button>Upraviť profil</Button>
</Link>
</div>
{/* Profile Card */}
<Card className="mb-6">
<CardContent className="p-8">
<div className="flex flex-col md:flex-row gap-8">
{/* Avatar */}
<div className="flex-shrink-0">
{profile.image ? (
<img
src={profile.image}
alt={profile.name}
className="w-32 h-32 rounded-full object-cover"
style={{ boxShadow: 'var(--shadow-lg)' }}
/>
) : (
<div
className="w-32 h-32 rounded-full gradient-primary flex items-center justify-center"
style={{ boxShadow: 'var(--shadow-lg)' }}
>
<span className="text-5xl font-bold text-white">
{profile.name.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
{/* Info */}
<div className="flex-1">
<h2 className="text-2xl font-bold text-[color:var(--fluent-text)] mb-2">
{profile.name}
</h2>
<p className="text-[color:var(--fluent-text-secondary)] mb-4">
{profile.email}
</p>
{profile.profile?.bio && (
<p className="text-[color:var(--fluent-text)] mb-6">
{profile.profile.bio}
</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* City */}
{profile.profile?.city && (
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[color:var(--fluent-surface-secondary)] rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-[color:var(--fluent-text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p className="text-sm text-[color:var(--fluent-text-secondary)]">Mesto</p>
<p className="text-[color:var(--fluent-text)] font-medium">{profile.profile.city}</p>
</div>
</div>
)}
{/* Phone */}
{profile.profile?.phone && (
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[color:var(--fluent-surface-secondary)] rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-[color:var(--fluent-text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
<div>
<p className="text-sm text-[color:var(--fluent-text-secondary)]">Telefón</p>
<p className="text-[color:var(--fluent-text)] font-medium">{profile.profile.phone}</p>
</div>
</div>
)}
{/* Skill Level */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[color:var(--fluent-surface-secondary)] rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-[color:var(--fluent-text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
</div>
<div>
<p className="text-sm text-[color:var(--fluent-text-secondary)]">Úroveň</p>
<p className="text-[color:var(--fluent-text)] font-medium">
{SKILL_LABELS[profile.profile?.skillLevel || 'BEGINNER']}
</p>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Favorite Sports */}
{profile.profile?.favoriteSports && profile.profile.favoriteSports.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Obľúbené športy</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{profile.profile.favoriteSports.map((sport) => (
<span
key={sport}
className="px-4 py-2 rounded-lg gradient-feature-1 text-white font-medium"
style={{ boxShadow: 'var(--shadow-sm)' }}
>
{SPORT_LABELS[sport] || sport}
</span>
))}
</div>
</CardContent>
</Card>
)}
{/* Empty State for No Info */}
{!profile.profile?.bio && !profile.profile?.city && !profile.profile?.phone &&
(!profile.profile?.favoriteSports || profile.profile.favoriteSports.length === 0) && (
<Card className="mt-6">
<CardContent className="text-center py-12">
<div className="w-20 h-20 bg-[color:var(--fluent-surface-secondary)] rounded-full mx-auto mb-6 flex items-center justify-center">
<svg className="w-10 h-10 text-[color:var(--fluent-text-tertiary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<h3 className="text-xl font-bold text-[color:var(--fluent-text)] mb-2">
Doplňte svoj profil
</h3>
<p className="text-[color:var(--fluent-text-secondary)] mb-6 max-w-md mx-auto">
Pridajte informácie o sebe, aby ostatní vedeli viac o vás a vašich športových preferenciách.
</p>
<Link href="/profile/edit">
<Button>Upraviť profil</Button>
</Link>
</CardContent>
</Card>
)}
</div>
</main>
);
}

View File

@ -124,6 +124,18 @@ export default function Navigation() {
<span>Dashboard</span>
</div>
</Link>
<Link
href="/profile"
className="block px-4 py-3 text-base font-medium text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-surface-secondary)]/50 transition-all"
onClick={() => setIsUserMenuOpen(false)}
>
<div className="flex items-center space-x-3">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>Profil</span>
</div>
</Link>
<button
onClick={handleSignOut}
className="w-full text-left px-4 py-3 text-base font-medium text-red-600 dark:text-red-400 hover:bg-red-50/50 dark:hover:bg-red-950/20 transition-all"
@ -251,6 +263,24 @@ export default function Navigation() {
<span>Dashboard</span>
</div>
</Link>
<Link
href="/profile"
className="block px-4 py-3 text-base font-semibold bg-blue-50/50 text-blue-600 hover:text-blue-700 hover:bg-blue-100/60 dark:bg-blue-950/40 dark:text-blue-400 dark:hover:text-blue-300 dark:hover:bg-blue-950/60 rounded-lg hover-glow reveal-effect transition-all duration-200"
style={{
boxShadow: 'var(--shadow-sm)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
animation: isMobileMenuOpen ? `slideDown 0.4s ease-out ${navLinks.length * 0.08 + 0.12}s both` : 'none'
}}
onClick={() => setIsMobileMenuOpen(false)}
>
<div className="flex items-center space-x-3">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>Profil</span>
</div>
</Link>
<button
onClick={handleSignOut}
className="w-full block px-4 py-3 text-base text-left font-semibold text-red-600 dark:text-red-400 hover:bg-red-50/50 dark:hover:bg-red-950/20 rounded-lg hover-glow reveal-effect transition-all duration-200"
@ -258,7 +288,7 @@ export default function Navigation() {
boxShadow: 'var(--shadow-sm)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
animation: isMobileMenuOpen ? `slideDown 0.4s ease-out ${navLinks.length * 0.08 + 0.16}s both` : 'none'
animation: isMobileMenuOpen ? `slideDown 0.4s ease-out ${navLinks.length * 0.08 + 0.20}s both` : 'none'
}}
>
<div className="flex items-center space-x-3">