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:
parent
84cf61586a
commit
9a6fd12b0f
@ -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.
|
||||
|
||||
|
||||
105
apps/backend/src/app/api/activities/my/route.ts
Normal file
105
apps/backend/src/app/api/activities/my/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
155
apps/backend/src/app/api/profile/route.ts
Normal file
155
apps/backend/src/app/api/profile/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
396
apps/frontend/src/app/profile/edit/page.tsx
Normal file
396
apps/frontend/src/app/profile/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
265
apps/frontend/src/app/profile/page.tsx
Normal file
265
apps/frontend/src/app/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user