From 9a6fd12b0f03cd1a294f31e3ca1c704d41e568ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jozef=20Koval=C4=8D=C3=ADn?= Date: Thu, 30 Oct 2025 20:43:28 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Implement=C3=A1cia=20US-002=20-=20Pou?= =?UTF-8?q?=C5=BE=C3=ADvate=C4=BEsk=C3=BD=20profil=20&=20Dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- USER_STORIES.md | 50 +-- .../src/app/api/activities/my/route.ts | 105 +++++ apps/backend/src/app/api/profile/route.ts | 155 +++++++ apps/frontend/src/app/dashboard/page.tsx | 278 ++++++++++-- apps/frontend/src/app/profile/edit/page.tsx | 396 ++++++++++++++++++ apps/frontend/src/app/profile/page.tsx | 265 ++++++++++++ apps/frontend/src/components/Navigation.tsx | 32 +- 7 files changed, 1222 insertions(+), 59 deletions(-) create mode 100644 apps/backend/src/app/api/activities/my/route.ts create mode 100644 apps/backend/src/app/api/profile/route.ts create mode 100644 apps/frontend/src/app/profile/edit/page.tsx create mode 100644 apps/frontend/src/app/profile/page.tsx diff --git a/USER_STORIES.md b/USER_STORIES.md index b3cab67..f0437b0 100644 --- a/USER_STORIES.md +++ b/USER_STORIES.md @@ -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. diff --git a/apps/backend/src/app/api/activities/my/route.ts b/apps/backend/src/app/api/activities/my/route.ts new file mode 100644 index 0000000..0e4978a --- /dev/null +++ b/apps/backend/src/app/api/activities/my/route.ts @@ -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 } + ); + } +} diff --git a/apps/backend/src/app/api/profile/route.ts b/apps/backend/src/app/api/profile/route.ts new file mode 100644 index 0000000..9698bde --- /dev/null +++ b/apps/backend/src/app/api/profile/route.ts @@ -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 } + ); + } +} diff --git a/apps/frontend/src/app/dashboard/page.tsx b/apps/frontend/src/app/dashboard/page.tsx index 1f89994..1467a74 100644 --- a/apps/frontend/src/app/dashboard/page.tsx +++ b/apps/frontend/src/app/dashboard/page.tsx @@ -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 = { + 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(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 (
@@ -64,8 +157,12 @@ export default function DashboardPage() { Moje aktivity -

0

-

Vytvorené aktivity

+

+ {activities?.stats.totalCreated || 0} +

+

+ Vytvorené aktivity ({activities?.stats.upcomingCreated || 0} nadchádzajúcich) +

@@ -79,8 +176,12 @@ export default function DashboardPage() { Prihlásený na -

0

-

Nadchádzajúcich aktivít

+

+ {activities?.stats.totalJoined || 0} +

+

+ Aktivít ({activities?.stats.upcomingJoined || 0} nadchádzajúcich) +

@@ -91,11 +192,15 @@ export default function DashboardPage() {
- Hodnotenie + Celkom aktivít -

-

-

Zatiaľ žiadne hodnotenia

+

+ {(activities?.stats.totalCreated || 0) + (activities?.stats.totalJoined || 0)} +

+

+ Účastí na športových aktivitách +

@@ -148,30 +253,137 @@ export default function DashboardPage() { - {/* Empty State */} - - -
- - - -
-

- Zatiaľ žiadne aktivity -

-

- Začnite vytvorením novej aktivity alebo sa pripojte k existujúcim aktivitám vo vašom okolí. -

-
- - - - - - -
-
-
+ {/* Activities Lists */} + {activities && (activities.created.length > 0 || activities.joined.length > 0) ? ( +
+ {/* Created Activities */} + {activities.created.length > 0 && ( +
+

+ Moje vytvorené aktivity +

+
+ {activities.created.map((activity) => ( + + + +
+ + {SPORT_LABELS[activity.sportType] || activity.sportType} + + + Organizátor + +
+

+ {activity.title} +

+
+
+ + + + {formatDate(activity.date)} +
+
+ + + + + {activity.venue.name}, {activity.venue.city} +
+
+ + + + {activity.currentParticipants}/{activity.maxParticipants} účastníkov +
+
+
+
+ + ))} +
+
+ )} + + {/* Joined Activities */} + {activities.joined.length > 0 && ( +
+

+ Aktivity, na ktoré som prihlásený +

+
+ {activities.joined.map((activity) => ( + + + +
+ + {SPORT_LABELS[activity.sportType] || activity.sportType} + + + Účastník + +
+

+ {activity.title} +

+
+
+ + + + {formatDate(activity.date)} +
+
+ + + + + {activity.venue.name}, {activity.venue.city} +
+
+ + + + Organizuje: {activity.organizer.name} +
+
+
+
+ + ))} +
+
+ )} +
+ ) : ( + /* Empty State */ + + +
+ + + +
+

+ Zatiaľ žiadne aktivity +

+

+ Začnite vytvorením novej aktivity alebo sa pripojte k existujúcim aktivitám vo vašom okolí. +

+
+ + + + + + +
+
+
+ )}
); diff --git a/apps/frontend/src/app/profile/edit/page.tsx b/apps/frontend/src/app/profile/edit/page.tsx new file mode 100644 index 0000000..d363a31 --- /dev/null +++ b/apps/frontend/src/app/profile/edit/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const [formData, setFormData] = useState({ + name: '', + bio: '', + phone: '', + city: '', + skillLevel: 'BEGINNER', + favoriteSports: [] as string[], + image: '', + }); + const [imagePreview, setImagePreview] = useState(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) => { + 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 ( +
+
+
+

Načítavam profil...

+
+
+ ); + } + + if (!session || !profile) { + return null; + } + + return ( +
+
+
+

+ Upraviť profil +

+

+ Aktualizujte svoje informácie a športové preferencie +

+
+ + + +
+
+ +
+
+ {imagePreview ? ( + Náhľad + ) : ( +
+ + {formData.name.charAt(0).toUpperCase()} + +
+ )} +
+ +
+ +
+ + {imagePreview && ( + + )} +
+

+ JPG, PNG alebo GIF. Maximum 2MB. +

+
+
+
+ +
+ + setFormData({ ...formData, name: e.target.value })} + required + placeholder="Vaše meno" + /> +
+ +
+ +