US-019, US-003, US-004, US-005 - KB

This commit is contained in:
XomByik 2025-11-04 17:33:11 +01:00
parent 081d285f34
commit 29829adb1d
28 changed files with 2149 additions and 23809 deletions

View File

@ -16,9 +16,17 @@ BETTER_AUTH_SECRET="change-this-to-a-random-secret-in-production-min-32-chars"
BETTER_AUTH_URL="http://localhost:3001"
# Frontend Configuration
# Frontend URL (used for password reset emails and external links)
NEXT_PUBLIC_FRONTEND_URL="http://localhost:3000"
# API URL for frontend to communicate with backend
NEXT_PUBLIC_API_URL="http://localhost:3001"
# Brevo Email Service (for password reset emails)
# Get API key from: https://app.brevo.com/settings/keys/api
# Leave empty for development mode (emails logged to console)
BREVO_API_KEY=""
# OAuth Providers (Optional - configure only if you want to enable OAuth)
# Google OAuth: https://console.cloud.google.com/apis/credentials
GOOGLE_CLIENT_ID=""

3
.gitignore vendored
View File

@ -8,6 +8,9 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
package-lock.json
yarn.lock
pnpm-lock.yaml
# Testing
coverage/

173
README.md
View File

@ -6,9 +6,9 @@ Moderná webová aplikácia pre športových nadšencov - hľadanie spoluhráčo
**Frontend:** Next.js (latest), React (latest), TypeScript (latest), Tailwind CSS (latest)
**Backend:** Next.js API Routes, Prisma ORM (latest), Better Auth (latest), PostgreSQL (latest)
**DevOps:** Docker & Docker Compose, Node.js (alpine), npm Workspaces
**DevOps:** Docker & Docker Compose, Node.js (alpine)
> **Poznámka:** Všetky verzie používajú latest Alpine Linux images a npm packages pre najnovšie stabilné vydania.
> **Poznámka:** Všetky verzie používajú najnovšie Alpine Linux obrazy a npm balíčky pre najnovšie stabilné vydania.
---
@ -26,11 +26,10 @@ Moderná webová aplikácia pre športových nadšencov - hľadanie spoluhráčo
git clone git@git.kemt.fei.tuke.sk:kb159dr/SportBuddy.git
cd sportbuddy
# 2. Skopíruj environment variables (DÔLEŽITÉ!)
# 2. Skopíruj premenné prostredia (DÔLEŽITÉ!)
cp .env.example .env
# Voliteľne: uprav .env pre vlastné nastavenia
# 3. Spusti Docker Compose (automaticky stiahne dependencies a spustí všetky služby)
# 3. Spusti Docker Compose (automaticky stiahne závislosti a spustí všetky služby)
docker-compose up -d
# 4. Otvor aplikáciu v prehliadači
@ -38,7 +37,7 @@ docker-compose up -d
# Backend API: http://localhost:3001/api
```
Prvé spustenie trvá ~1-2 minúty (sťahovanie images + npm install).
Prvé spustenie trvá ~1-2 minúty (sťahovanie obrazov + npm install).
---
@ -48,27 +47,27 @@ Prvé spustenie trvá ~1-2 minúty (sťahovanie images + npm install).
**⚠️ Pred spustením: Uisti sa, že máš `.env` súbor (pozri krok 2 v inštalácii vyššie)**
1. **Docker stiahne images:**
1. **Docker stiahne obrazy:**
- `postgres:alpine` (databáza)
- `node:alpine` (Node.js runtime)
- `node:alpine` (Node.js prostredie)
2. **Backend automaticky:**
- Nainštaluje npm dependencies
- Vygeneruje Prisma Client
- Nainštaluje npm závislosti
- Vygeneruje Prisma klienta
- Vytvorí databázové tabuľky (`prisma db push`)
- Spustí development server na porte **3001**
- Spustí vývojársky server na porte **3001**
3. **Frontend automaticky:**
- Nainštaluje npm dependencies
- Spustí development server na porte **3000**
- Nainštaluje npm závislosti
- Spustí vývojársky server na porte **3000**
4. **PostgreSQL:**
- Vytvorí databázu `sportbuddy`
- Beží na porte **5432**
### Hot Reload (automatické načítanie zmien)
### Automatické načítanie zmien (Hot Reload)
Vďaka **volume mounts** - okamžitý hot reload:
Vďaka **pripojeniu zväzkov (volume mounts)** - okamžité načítanie zmien:
```
Zmeníš súbor → Uložíš (Ctrl+S) → Zmena sa okamžite prejaví v prehliadači
@ -78,18 +77,18 @@ Nemusíš reštartovať Docker kontajnery!
---
## Docker príkazy pre development
## Príkazy Docker pre vývoj
```bash
# Spustenie všetkých služieb
docker-compose up -d
# Sledovanie logov (užitočné pre debugging)
# Sledovanie záznamov (užitočné pre ladenie)
docker-compose logs -f
docker-compose logs -f backend # len backend
docker-compose logs -f frontend # len frontend
# Rebuild po zmene Dockerfile
# Opätovné zostavenie po zmene Dockerfile
docker-compose up -d --build
# Zastavenie služieb
@ -98,11 +97,11 @@ docker-compose down
# Vyčistenie všetkého (vrátane databázy!)
docker-compose down -v
# Exec do kontajnera (pre manuálne príkazy)
# Pripojenie do kontajnera (pre manuálne príkazy)
docker-compose exec backend sh
docker-compose exec frontend sh
# Prisma Studio (GUI pre databázu)
# Prisma Studio (grafické rozhranie pre databázu)
docker-compose exec backend npx prisma studio
# Otvor: http://localhost:5555
```
@ -115,40 +114,82 @@ docker-compose exec backend npx prisma studio
sportbuddy/
├── apps/
│ ├── backend/ # Backend API (Next.js + Prisma)
│ │ ├── src/
│ │ │ ├── app/api/ # API endpoints
│ │ │ └── lib/ # Server utilities (auth, prisma)
│ │ ├── prisma/
│ │ │ └── schema.prisma # Databázová schéma
│ │ ├── Dockerfile # Unifikovaný Docker image (dev + prod)
│ │ └── package.json
│ │ │ ├── schema.prisma # Databázová schéma
│ │ │ └── seed.ts # Počiatočné dáta do databázy (športoviská)
│ │ ├── src/
│ │ │ ├── app/api/ # API koncové body
│ │ │ │ ├── activities/ # Activity CRUD + prihlásenie/odhlásenie
│ │ │ │ ├── auth/ # Better Auth koncové body + vlastný reset hesla
│ │ │ │ ├── profile/ # API používateľského profilu
│ │ │ │ └── venues/ # API športovísk
│ │ │ └── lib/ # Serverové utility
│ │ │ ├── auth.ts # Better Auth konfigurácia
│ │ │ ├── auth-client.ts # Nastavenie Auth klienta
│ │ │ ├── email.ts # Brevo emailová služba (na reset hesla)
│ │ │ ├── get-session.ts # Session helper
│ │ │ └── prisma.ts # Prisma klient
│ │ ├── Dockerfile # Multi-stage Docker (dev + prod)
│ │ ├── middleware.ts # Next.js middleware
│ │ ├── next.config.mjs # Next.js konfigurácia (CORS hlavičky)
│ │ ├── package.json # Závislosti
│ │ └── tsconfig.json # TypeScript konfigurácia
│ │
│ └── frontend/ # Frontend UI (Next.js + React)
│ ├── public/
│ │ ├── manifest.json # PWA manifest
│ │ └── sw.js # Service Worker
│ ├── src/
│ │ ├── app/ # Next.js App Router stránky
│ │ │ ├── auth/ # Autentifikačné stránky (prihlásenie, registrácia)
│ │ │ ├── dashboard/ # Stránka dashboardu
│ │ │ ├── profile/ # Stránky profilu
│ │ │ ├── globals.css # Globálne štýly
│ │ │ ├── layout.tsx # Hlavné rozloženie
│ │ │ └── page.tsx # Domovská stránka
│ │ ├── components/ # React komponenty
│ │ └── contexts/ # React Context (theme, atď.)
│ ├── Dockerfile # Unifikovaný Docker image (dev + prod)
│ └── package.json
│ │ │ ├── ui/ # UI elementy (Button, Card, Input)
│ │ │ ├── HtmlWrapper.tsx
│ │ │ ├── Navigation.tsx
│ │ │ ├── TemplateWrapper.tsx
│ │ │ └── ThemeToggle.tsx
│ │ ├── contexts/
│ │ │ └── ThemeContext.tsx # Kontext tmavého režimu
│ │ └── lib/
│ │ └── auth-client.ts # Better Auth klient
│ ├── Dockerfile # Viacstupňový Docker (dev + prod)
│ ├── next.config.mjs # Next.js konfigurácia
│ ├── package.json # Závislosti
│ ├── postcss.config.mjs # PostCSS konfigurácia
│ ├── tailwind.config.ts # Tailwind CSS konfigurácia
│ └── tsconfig.json # TypeScript konfigurácia
├── packages/
│ └── shared/ # Zdieľané TypeScript typy
│ └── src/types/ # SportType, SkillLevel, atď.
│ ├── src/
│ │ ├── types/
│ │ │ └── index.ts # Zdieľané typy (SportType, SkillLevel, atď.)
│ │ └── index.ts # Exporty balíčka
│ └── package.json
├── docker-compose.yml # Docker konfigurácia
├── .env # Environment variables (lokálne, NIE v Gite!)
├── .env.example # Template (commituj do Gitu)
├── README.md # Návod na používanie
└── USER_STORIES.md # Prehľad user stories a taskov
├── .dockerignore # Dockerignore
├── .env # Premenné prostredia (len lokálne, nie v Gite!)
├── .env.example # Šablóna premenných prostredia (commituj toto)
├── .gitignore # Gitignore
├── docker-compose.yml # Docker Compose konfigurácia (3 služby)
├── README.md # Projektová dokumentácia
└── USER_STORIES.md # User stories & tasky
```
**Poznámka:** V Docker projekte nepotrebujeme root `package.json`, `tsconfig.json` ani `package-lock.json` súbory. Každá aplikácia má vlastné závislosti.
---
## Environment Variables
## Premenné prostredia
### Konfigurácia (.env súbor)
Projekt používa jeden `.env` súbor v roote. **Nikdy necommituj `.env` do Gitu!**
Projekt používa jeden `.env` súbor v root adresári. **Nikdy necommituj `.env` do Gitu!**
**Pre nových vývojárov:**
```bash
@ -167,6 +208,8 @@ POSTGRES_DB=sportbuddy
DATABASE_URL="postgresql://sportbuddy:sportbuddy123@postgres:5432/sportbuddy"
BETTER_AUTH_SECRET="change-this-in-production"
BETTER_AUTH_URL="http://localhost:3001"
BREVO_API_KEY="tvoj-skutocny-brevo-api-key"
pozn. - V apps/backend/src/lib/email.ts zmeň sender email na svoj overený email
# Frontend
NEXT_PUBLIC_API_URL="http://localhost:3001"
@ -178,11 +221,11 @@ GOOGLE_CLIENT_SECRET=""
---
## Development workflow
## Pracovný postup pri vývoji
### 1. Práca na user stories
Pozri [USER_STORIES.md](USER_STORIES.md) pre aktuálny stav projektu a zoznam taskov.
Pozri [USER_STORIES.md](USER_STORIES.md) pre aktuálny stav projektu a zoznam úloh.
### 2. Práca s databázou (Prisma)
@ -191,27 +234,27 @@ Pozri [USER_STORIES.md](USER_STORIES.md) pre aktuálny stav projektu a zoznam ta
# → Potom spusti:
docker-compose exec backend npx prisma db push
# Otvor Prisma Studio (GUI)
# Otvor Prisma Studio (grafické rozhranie)
docker-compose exec backend npx prisma studio
# Reset databázy (POZOR: vymaže všetky dáta!)
docker-compose exec backend npx prisma db push --force-reset
```
### 3. Debugging
### 3. Ladenie
```bash
# Backend logy (API requesty, errory)
# Záznamy backendu (API požiadavky, chyby)
docker-compose logs -f backend
# Frontend logy (build output, errors)
# Záznamy frontendu (výstup zostavenia, chyby)
docker-compose logs -f frontend
# Databáza logy
# Záznamy databázy
docker-compose logs -f postgres
```
### 4. Pridávanie nových dependencies
### 4. Pridávanie nových závislostí
```bash
# Backend
@ -229,20 +272,20 @@ docker-compose restart frontend
---
## Docker architektúra
## Architektúra Docker projektu
### Unifikované Dockerfiles
### Zjednotené Dockerfiles
Každý Dockerfile má **multi-stage build** s dvoma režimami:
Každý Dockerfile má **viacstupňové zostavenie (multi-stage build)** s dvoma režimami:
- **Development stage** (používa docker-compose.yml)
- Hot reload cez volume mounts
- **Vývojársky režim** (používa docker-compose.yml)
- Automatické načítanie zmien cez pripojenie zväzkov
- `npm run dev`
- Debug-friendly
- Vhodné pre ladenie
- **Production stage** (pre nasadenie)
- Optimalizovaný build
- Multi-stage image (menší size)
- **Produkčný režim** (pre nasadenie)
- Optimalizované zostavenie
- Viacstupňový obraz (menšia veľkosť)
- `npm run build`
### Ako to funguje?
@ -269,25 +312,25 @@ docker build --target production -t sportbuddy-frontend .
---
## Pre vývojárov - Best Practices
## Pre vývojárov - Osvedčené postupy
### ✅ Commituj:
- Všetok kód v `apps/*/src/`
- `package.json`, `package-lock.json` (po pridaní dependencies)
- `package.json`, `package-lock.json` (po pridaní závislostí)
- `prisma/schema.prisma` (po zmene schémy)
- `Dockerfile`, `docker-compose.yml`
- `.env.example` (template bez secrets)
### ❌ Necommituj:
- `node_modules/` (automaticky ignorované)
- `.next/` (build artefakty)
- `.env` (obsahuje secrets - NIKDY necommituj!)
- `.vscode/`, `.idea/` (IDE nastavenia)
- `.next/` (výsledky zostavenia obrazov)
- `.env` (obsahuje tajné kľúče - NIKDY necommituj!)
- `.vscode/`, `.idea/` (nastavenia IDE)
### 🔄 Po každom git pull:
```bash
# Ak niekto zmenil Dockerfile alebo dependencies
# Ak niekto zmenil Dockerfile alebo závislosti
docker-compose down
docker-compose up -d --build
```
@ -295,10 +338,10 @@ docker-compose up -d --build
### 🐛 Keď niečo nefunguje:
```bash
# 1. Skús restart
# 1. Skús reštart
docker-compose restart
# 2. Skús rebuild
# 2. Skús opätovné zostavenie
docker-compose up -d --build
# 3. Vyčisti všetko a začni odznova
@ -306,7 +349,7 @@ docker-compose down -v
cp .env.example .env # Obnov .env ak bol zmazaný
docker-compose up -d --build
# 4. Skontroluj logy
# 4. Skontroluj záznamy
docker-compose logs -f
```
@ -314,8 +357,8 @@ docker-compose logs -f
## Ďalšie kroky
1. **Prečítaj si** [USER_STORIES.md](USER_STORIES.md) - zoznam všetkých user stories a ich stav
2. **Vyber si task**
1. **Prečítaj si** [USER_STORIES.md](USER_STORIES.md) - zoznam všetkých používateľských príbehov a ich stav
2. **Vyber si úlohu**
3. **Pozri databázovú schému** - `apps/backend/prisma/schema.prisma`
4. **Pozri API - backend** - `apps/backend/src/app/api/`

View File

@ -20,7 +20,7 @@ aby som mohol používať aplikáciu
- ✅ Registračný formulár (/register)
- ✅ Prihlasovací formulár (/login)
- ✅ Validácia (email formát, heslo min 8 znakov)
- ✅ Hash hesla (bcrypt)
- ✅ Hash hesla (scrypt via Better Auth)
- ✅ Session management (localStorage)
- ✅ Responzívny dizajn formulárov
@ -78,7 +78,7 @@ aby som mohol prezentovať svoje športové záujmy a mať prehľad o mojich uda
## US-003: Vytvorenie aktivity
**Status:** 🔄 WIP (Work In Progress)
**Status:** ✅ HOTOVÉ
Ako používateľ
chcem vytvoriť novú športovú aktivitu
@ -91,26 +91,26 @@ aby som našiel spoluhráčov
- ✅ API: POST /api/activities
- ✅ Automatické pridanie tvorcu ako účastníka
- ✅ Validácia (dátum v budúcnosti, cena >= 0)
- ⏸️ Formulár na vytvorenie (/activities/create)
- ⏸️ Polia: názov, šport (dropdown), dátum, čas, miesto, max hráčov, úroveň, cena, popis
- ⏸️ React Hook Form + Zod validácia
- ⏸️ Loading state pri submit
- ⏸️ Redirect na detail po vytvorení
- ⏸️ Responzívny formulár
- Formulár na vytvorenie (/activities/create)
- Polia: názov, šport (dropdown), dátum, čas, miesto, max hráčov, úroveň, cena, popis
- React Hook Form + Zod validácia
- Loading state pri submit
- Redirect na detail po vytvorení
- Responzívny formulár
### Výsledné funkcie:
- ✅ API endpoint funguje
- ✅ Funkčná validácia na BE
- ✅ Aktivita sa uloží do DB
- ⏸️ Frontend formulár chýba
**Poznámka:** API je hotové, ale chýba frontend formulár na vytvorenie aktivity.
- ✅ Frontend formulár implementovaný a funkčný
- ✅ Automatické načítanie venues
- ✅ Validácia na FE a BE
---
## US-004: Zoznam a detail aktivít
**Status:** 🔄 WIP (Work In Progress)
**Status:** ✅ HOTOVÉ
Ako používateľ
chcem vidieť zoznam aktivít a ich detail
@ -122,30 +122,30 @@ aby som vedel, čo je k dispozícii
- ✅ API: GET /api/activities (pagination 20/page)
- ✅ API: GET /api/activities/[id]
- ✅ Filtrovanie podľa športu, mesta, statusu
- ⏸️ Stránka zoznamu (/activities)
- ⏸️ Card komponenta pre aktivitu
- ⏸️ Zobrazenie: názov, šport, dátum, čas, miesto, voľné miesta
- ⏸️ Loading skeleton
- ⏸️ Empty state ("Žiadne aktivity")
- ⏸️ Detail stránka (/activities/[id])
- ⏸️ Kompletné info + mapa (Google Maps embed)
- ⏸️ Zoznam účastníkov
- ⏸️ Progress bar obsadenosti
- ⏸️ Responzívny grid/detail
- Stránka zoznamu (/activities)
- Card komponenta pre aktivitu
- Zobrazenie: názov, šport, dátum, čas, miesto, voľné miesta
- Loading skeleton
- Empty state ("Žiadne aktivity")
- Detail stránka (/activities/[id])
- Kompletné info + mapa (Google Maps embed)
- Zoznam účastníkov
- Progress bar obsadenosti
- Responzívny grid/detail
### Výsledné funkcie:
- ✅ API endpoints fungujú
- ⏸️ Zoznam aktivít (UI)
- ⏸️ Detail aktivity (UI)
- ⏸️ Pagination funguje
**Poznámka:** Backend API je kompletný s filtrovaním, ale chýba celý frontend UI.
- ✅ Zoznam aktivít (UI) s kartami
- ✅ Detail aktivity s kompletnou informáciou
- ✅ Mapa športoviska
- ✅ Progress bar a zoznam účastníkov
- ✅ Loading states a empty states
---
## US-005: Prihlasovanie na aktivity
**Status:** 🔄 WIP (Work In Progress)
**Status:** ✅ HOTOVÉ
Ako používateľ
chcem sa prihlásiť na aktivitu
@ -159,20 +159,20 @@ aby som rezervoval miesto
- ✅ Kontrola voľnej kapacity
- ✅ Kontrola duplicity (už prihlásený)
- ✅ Aktualizácia počtu hráčov
- ⏸️ Tlačidlo "Prihlásiť sa" na detaile
- ⏸️ Tlačidlo "Odhlásiť sa" ak som prihlásený
- ⏸️ API: DELETE /api/activities/[id]/leave
- ⏸️ Optimistic updates (TanStack Query)
- ⏸️ Toast notifikácie
- ⏸️ Badge "Prihlásený" na karte aktivity
- Tlačidlo "Prihlásiť sa" na detaile
- Tlačidlo "Odhlásiť sa" ak som prihlásený
- ✅ API: DELETE /api/activities/[id]/join
- Optimistic updates (TanStack Query)
- Toast notifikácie
- Badge "Prihlásený" na karte aktivity
### Výsledné funkcie:
- ✅ Prihlásenie funguje (API)
- ⏸️ Odhlásenie funguje
- ✅ Prihlásenie funguje (API + UI)
- ✅ Odhlásenie funguje (API + UI)
- ✅ Počet hráčov sa aktualizuje
- ⏸️ UI komponenty chýbajú
**Poznámka:** Join API je hotové, ale chýba leave endpoint a všetky UI elementy.
- ✅ UI komponenty implementované
- ✅ Automatický refresh dát po akcii
- ✅ Vizuálna indikácia stavu (organizátor/účastník)
---
@ -805,6 +805,55 @@ aby som hral s ľuďmi na mojej úrovni
- ⏸️ User suggestions
- ⏸️ Weekly digest
---
## US-019: Reset hesla cez email
**Status:** ✅ HOTOVÉ
Ako používateľ
chcem si obnoviť heslo ak som ho zabudol
aby som sa mohol znova prihlásiť do aplikácie
**Vývojár:** Kamil Berecký
### Tasky:
- ✅ Prisma schema: PasswordReset model (token, userId, expiresAt, used)
- ✅ Email service setup (Brevo - 300 emailov/deň zadarmo)
- ✅ Verified sender email (kberecky@gmail.com) v Brevo dashboard
- ✅ API: POST /api/auth/forgot-password (generuje token a posiela email)
- ✅ API: POST /api/auth/reset-password (resetuje heslo s tokenom)
- ✅ Email template pre reset link (HTML s responsive dizajnom)
- ✅ Stránka /auth/forgot-password (formulár na zadanie emailu)
- ✅ Stránka /auth/reset-password?token=xxx (formulár na nové heslo)
- ✅ Validácia tokenu (expiracia 1 hodina, used flag)
- ✅ Bezpečné generovanie tokenu (crypto.randomBytes + SHA-256)
- ✅ Hash nového hesla (scrypt s Better Auth parametrami: N:16384, r:16, p:1, dkLen:64)
- ✅ Vymazanie tokenu po použití (used = true)
- ✅ Správne URL pre frontend (NEXT_PUBLIC_FRONTEND_URL)
- ✅ "Zabudli ste heslo?" link na signin stránke
- ✅ Success/Error feedback messages (slovenský jazyk)
- ✅ Rate limiting (max 3 requesty za hodinu na email)
- ✅ Responzívny dizajn
- ✅ Development mode (console log namiesto emailu keď BREVO_API_KEY="brevo_test_key")
- ✅ Production mode (Brevo API integration s messageId tracking)
- ✅ Všetky sessions sa vymažú po resete (force re-login)
### Výsledné funkcie:
- ✅ Odoslanie reset emailu funguje (Brevo API)
- ✅ Token validácia funguje (expiracia, duplicita, použitie)
- ✅ Reset hesla funguje s kompatibilným scrypt hashom
- ✅ Email template je pekný a funkčný s correct frontend URL
- ✅ Development mode bez Brevo API kľúča (console log)
- ✅ Production ready s Brevo integration
- ✅ Všetky sessions sa vymažú po resete (security)
- ✅ Prihlásenie funguje po resete hesla
**Technické detaily:**
- Email service: Brevo (@getbrevo/brevo SDK)
- Password hashing: Node.js crypto scrypt (Better Auth compatible)
- Token storage: PostgreSQL (PasswordReset table)
- Email delivery: 300/day limit (Brevo free tier)
---

View File

@ -0,0 +1,15 @@
node_modules
.next
.turbo
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
*.pem
.env*.local
.vscode
.idea
*.swp
*.swo
*~
.git

View File

@ -1,23 +0,0 @@
# Database Configuration
# For Docker: postgresql://sportbuddy:sportbuddy123@postgres:5432/sportbuddy?schema=public
# For local: postgresql://sportbuddy:sportbuddy123@localhost:5432/sportbuddy?schema=public
DATABASE_URL="postgresql://sportbuddy:sportbuddy123@postgres:5432/sportbuddy?schema=public"
# Better Auth Configuration
# Generate a random 32+ character secret for production
# Example: openssl rand -base64 32
BETTER_AUTH_SECRET="change-this-to-a-random-secret-in-production-min-32-chars"
# Backend URL (used for Better Auth callbacks)
# For Docker: http://localhost:3001
# For production: https://your-backend-domain.com
BETTER_AUTH_URL="http://localhost:3001"
# OAuth Providers (Optional)
# Google OAuth 2.0 credentials from https://console.cloud.google.com/
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
# Apple OAuth credentials from https://developer.apple.com/
APPLE_CLIENT_ID=""
APPLE_CLIENT_SECRET=""

File diff suppressed because it is too large Load Diff

View File

@ -12,11 +12,15 @@
"lint": "next lint",
"prisma:generate": "prisma generate",
"prisma:push": "prisma db push",
"prisma:migrate": "prisma migrate dev"
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "tsx prisma/seed.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@getbrevo/brevo": "latest",
"@prisma/client": "latest",
"bcryptjs": "latest",
"better-auth": "latest",
"next": "latest",
"react": "latest",
@ -29,6 +33,7 @@
"eslint": "latest",
"eslint-config-next": "latest",
"prisma": "latest",
"tsx": "latest",
"typescript": "latest"
}
}

View File

@ -30,6 +30,7 @@ model User {
participations Participation[]
reviews Review[]
favoriteVenues VenueFavorite[]
passwordResets PasswordReset[]
}
// Profile model for user details (separate from auth)
@ -89,6 +90,20 @@ model Verification {
@@unique([identifier, value])
}
model PasswordReset {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
used Boolean @default(false)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([token])
@@index([userId])
}
// Sport types enumeration
enum SportType {
FOOTBALL

View File

@ -0,0 +1,97 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
console.log("🌱 Seeding database...");
// Create test venues
const venues = [
{
name: "Štadión Lokomotíva",
description: "Moderný futbalový štadión s umelým povrchom",
address: "Rastislavova 23",
city: "Košice",
latitude: 48.7164,
longitude: 21.2611,
sportTypes: ["FOOTBALL", "RUNNING"],
amenities: ["parking", "showers", "lockers"],
priceRange: "10-15€/hod",
phone: "+421 55 123 4567",
},
{
name: "Steel Aréna",
description: "Multifunkčná športová hala",
address: "Moldavská cesta 10",
city: "Košice",
latitude: 48.6975,
longitude: 21.2422,
sportTypes: ["BASKETBALL", "VOLLEYBALL", "BADMINTON"],
amenities: ["parking", "showers", "lockers", "cafe"],
priceRange: "15-20€/hod",
phone: "+421 55 234 5678",
},
{
name: "Tenisové kurty Anička",
description: "Vonkajšie a kryte tenisové kurty",
address: "Anička 12",
city: "Košice",
latitude: 48.7372,
longitude: 21.2599,
sportTypes: ["TENNIS"],
amenities: ["parking", "showers"],
priceRange: "12-18€/hod",
phone: "+421 55 345 6789",
},
{
name: "FitClub Gym",
description: "Moderná posilňovňa s najnovším vybavením",
address: "Hlavná 45",
city: "Košice",
latitude: 48.7214,
longitude: 21.2581,
sportTypes: ["GYM"],
amenities: ["showers", "lockers", "sauna"],
priceRange: "5-10€/vstup",
phone: "+421 55 456 7890",
},
{
name: "Plavecká hala Košice",
description: "50m bazén s tribunou",
address: "Trieda SNP 30",
city: "Košice",
latitude: 48.7098,
longitude: 21.2451,
sportTypes: ["SWIMMING"],
amenities: ["parking", "showers", "lockers", "cafe"],
priceRange: "3-5€/vstup",
phone: "+421 55 567 8901",
},
];
for (const venue of venues) {
const existing = await prisma.venue.findFirst({
where: { name: venue.name },
});
if (!existing) {
const created = await prisma.venue.create({
data: venue as any,
});
console.log(`✓ Created venue: ${created.name}`);
} else {
console.log(`⊘ Skipped existing venue: ${venue.name}`);
}
}
console.log("✅ Seeding completed!");
}
main()
.catch((e) => {
console.error("❌ Seeding failed:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -68,7 +68,7 @@ export async function POST(
data: {
userId: session.user.id,
activityId: id,
status: "confirmed",
status: "CONFIRMED",
},
});

View File

@ -21,7 +21,6 @@ export async function GET(
name: true,
email: true,
image: true,
skillLevel: true,
},
},
participations: {
@ -31,7 +30,6 @@ export async function GET(
id: true,
name: true,
image: true,
skillLevel: true,
},
},
},

View File

@ -124,7 +124,7 @@ export async function POST(request: NextRequest) {
data: {
userId: session.user.id,
activityId: activity.id,
status: "confirmed",
status: "CONFIRMED",
},
});

View File

@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { sendPasswordResetEmail } from "@/lib/email";
import crypto from "crypto";
// POST /api/auth/forgot-password
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email } = body;
if (!email || typeof email !== "string") {
return NextResponse.json({ error: "Email je povinný" }, { status: 400 });
}
// Find user by email
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase().trim() },
});
// Always return success (security best practice - don't reveal if email exists)
if (!user) {
return NextResponse.json({
message:
"Ak email existuje v našej databáze, poslali sme ti inštrukcie na reset hesla.",
});
}
// Delete any existing reset tokens for this user
await prisma.passwordReset.deleteMany({
where: { userId: user.id },
});
// Generate secure random token
const resetToken = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
// Create password reset record
await prisma.passwordReset.create({
data: {
userId: user.id,
token: resetToken,
expiresAt,
},
});
// Send email
const emailResult = await sendPasswordResetEmail({
email: user.email,
resetToken,
userName: user.name,
});
if (!emailResult.success) {
console.error("Failed to send reset email:", emailResult.error);
return NextResponse.json(
{ error: "Chyba pri odosielaní emailu. Skús to znova." },
{ status: 500 }
);
}
return NextResponse.json({
message:
"Ak email existuje v našej databáze, poslali sme ti inštrukcie na reset hesla.",
});
} catch (error) {
console.error("Error in forgot-password:", error);
return NextResponse.json(
{ error: "Nastala chyba. Skús to znova." },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { scrypt, randomBytes } from "node:crypto";
import { promisify } from "node:util";
const scryptAsync = promisify(scrypt);
// POST /api/auth/reset-password
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { token, password } = body;
if (!token || typeof token !== "string") {
return NextResponse.json({ error: "Token je povinný" }, { status: 400 });
}
if (!password || typeof password !== "string" || password.length < 8) {
return NextResponse.json(
{ error: "Heslo musí mať minimálne 8 znakov" },
{ status: 400 }
);
}
// Find reset token
const resetRecord = await prisma.passwordReset.findUnique({
where: { token },
include: { user: true },
});
if (!resetRecord) {
return NextResponse.json(
{ error: "Neplatný alebo expirovaný token" },
{ status: 400 }
);
}
// Check if token is expired
if (resetRecord.expiresAt < new Date()) {
// Delete expired token
await prisma.passwordReset.delete({
where: { id: resetRecord.id },
});
return NextResponse.json(
{ error: "Token expiroval. Požiadaj o nový reset link." },
{ status: 400 }
);
}
// Check if token was already used
if (resetRecord.used) {
return NextResponse.json(
{ error: "Token bol už použitý" },
{ status: 400 }
);
}
// Hash password using scrypt (same as Better Auth)
// Better Auth uses scrypt from @noble/hashes with these exact parameters:
// N: 16384, r: 16, p: 1, dkLen: 64
// Format: salt:key (both hex-encoded)
const salt = randomBytes(16).toString("hex");
const derivedKey = (await scryptAsync(
password.normalize("NFKC"), // Better Auth normalizes password
salt,
64, // dkLen
{
N: 16384,
r: 16,
p: 1,
maxmem: 128 * 16384 * 16 * 2, // 128 * N * r * 2
}
)) as Buffer;
const hashedPassword = `${salt}:${derivedKey.toString("hex")}`;
// Update user password in Account table
await prisma.account.updateMany({
where: {
userId: resetRecord.userId,
providerId: "credential",
},
data: {
password: hashedPassword,
},
});
// Mark token as used
await prisma.passwordReset.update({
where: { id: resetRecord.id },
data: { used: true },
});
// Delete all sessions for this user (force re-login)
await prisma.session.deleteMany({
where: { userId: resetRecord.userId },
});
return NextResponse.json({
message: "Heslo bolo úspešne zmenené. Môžeš sa prihlásiť.",
});
} catch (error) {
console.error("Error in reset-password:", error);
return NextResponse.json(
{ error: "Nastala chyba. Skús to znova." },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,186 @@
import * as brevo from "@getbrevo/brevo";
// In development without Brevo API key, we'll just log the email
const HAS_BREVO_KEY =
!!process.env.BREVO_API_KEY && process.env.BREVO_API_KEY !== "brevo_test_key";
// Initialize Brevo API client
let apiInstance: brevo.TransactionalEmailsApi | null = null;
if (HAS_BREVO_KEY) {
try {
apiInstance = new brevo.TransactionalEmailsApi();
apiInstance.setApiKey(
brevo.TransactionalEmailsApiApiKeys.apiKey,
process.env.BREVO_API_KEY!
);
} catch (error) {
console.error("Failed to initialize Brevo API:", error);
apiInstance = null;
}
}
interface SendPasswordResetEmailParams {
email: string;
resetToken: string;
userName: string;
}
export async function sendPasswordResetEmail({
email,
resetToken,
userName,
}: SendPasswordResetEmailParams) {
// Use frontend URL for password reset link (not backend URL!)
const frontendUrl =
process.env.NEXT_PUBLIC_FRONTEND_URL || "http://localhost:3000";
const resetUrl = `${frontendUrl}/auth/reset-password?token=${resetToken}`;
// Development mode without Brevo - just log the reset link
if (!HAS_BREVO_KEY) {
console.log("\n" + "=".repeat(80));
console.log("📧 PASSWORD RESET EMAIL (Development Mode)");
console.log("=".repeat(80));
console.log(`To: ${email}`);
console.log(`User: ${userName}`);
console.log(`Reset URL: ${resetUrl}`);
console.log("=".repeat(80) + "\n");
return {
success: true,
data: {
id: "dev-mode",
message: "Email logged to console in development mode",
},
};
}
try {
const sendSmtpEmail = new brevo.SendSmtpEmail();
sendSmtpEmail.subject = "SportBuddy - Reset hesla";
sendSmtpEmail.to = [{ email, name: userName }];
// Use your verified Brevo sender email (the one you registered with)
sendSmtpEmail.sender = {
name: "SportBuddy",
email: "kberecky@gmail.com", // Change to your verified Brevo email
};
sendSmtpEmail.htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 10px;
padding: 30px;
margin: 20px 0;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
font-size: 32px;
font-weight: bold;
color: #0066cc;
margin-bottom: 10px;
}
.content {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.button {
display: inline-block;
padding: 14px 32px;
background-color: #0066cc;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
}
.button:hover {
background-color: #0052a3;
}
.info-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}
.footer {
text-align: center;
margin-top: 30px;
color: #666;
font-size: 14px;
}
.link {
color: #0066cc;
word-break: break-all;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo"> SportBuddy</div>
</div>
<div class="content">
<h2 style="color: #333; margin-top: 0;">Ahoj ${userName}! 👋</h2>
<p>Dostali sme požiadavku na reset tvojho hesla pre SportBuddy účet.</p>
<p>Pre obnovenie hesla klikni na tlačidlo nižšie:</p>
<div style="text-align: center;">
<a href="${resetUrl}" class="button">Resetovať heslo</a>
</div>
<div class="info-box">
<strong> Dôležité:</strong> Tento link je platný iba <strong>1 hodinu</strong>.
</div>
<p>Ak tlačidlo nefunguje, skopíruj a vlož tento link do prehliadača:</p>
<p class="link">${resetUrl}</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #666; font-size: 14px;">
<strong>Nepoža doval si reset hesla?</strong><br>
Môžeš tento email ignorovať. Tvoje heslo ostane nezmenené.
</p>
</div>
<div class="footer">
<p>SportBuddy - Nájdi si spoluhráčov! 🏀🎾</p>
<p style="font-size: 12px; color: #999;">
Tento email bol odoslaný automaticky. Neodpovedaj naň.
</p>
</div>
</div>
</body>
</html>
`;
const data = await apiInstance!.sendTransacEmail(sendSmtpEmail);
console.log("Password reset email sent:", data);
return { success: true, data };
} catch (error) {
console.error("Failed to send email:", error);
return { success: false, error };
}
}

View File

@ -0,0 +1,15 @@
node_modules
.next
.turbo
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
*.pem
.env*.local
.vscode
.idea
*.swp
*.swo
*~
.git

View File

@ -1,6 +0,0 @@
# Frontend Environment Variables
# API URL - Backend endpoint
# For Docker: http://localhost:3001
# For production: https://your-backend-domain.com
NEXT_PUBLIC_API_URL="http://localhost:3001"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,464 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
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;
latitude: number | null;
longitude: number | null;
};
organizer: {
id: string;
name: string;
email: string;
image: string | null;
};
participations: {
id: string;
user: {
id: string;
name: string;
image: string | null;
};
}[];
}
const sportTypeLabels: 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: "💪 Posilňovňa",
OTHER: "🎯 Iné",
};
const skillLevelLabels: Record<string, string> = {
BEGINNER: "Začiatočník",
INTERMEDIATE: "Mierne pokročilý",
ADVANCED: "Pokročilý",
EXPERT: "Expert",
};
const statusLabels: Record<string, { label: string; color: string }> = {
OPEN: { label: "Otvorená", color: "bg-green-500" },
FULL: { label: "Plná", color: "bg-orange-500" },
CANCELLED: { label: "Zrušená", color: "bg-red-500" },
COMPLETED: { label: "Ukončená", color: "bg-gray-500" },
};
export default function ActivityDetailPage() {
const params = useParams();
const router = useRouter();
const [activity, setActivity] = useState<Activity | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [joining, setJoining] = useState(false);
const [leaving, setLeaving] = useState(false);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
useEffect(() => {
fetchActivity();
fetchCurrentUser();
}, [params.id]);
const fetchCurrentUser = async () => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/profile`,
{
credentials: "include",
}
);
if (response.ok) {
const data = await response.json();
setCurrentUserId(data.id);
}
} catch (err) {
console.error("Error fetching user:", err);
}
};
const fetchActivity = async () => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/activities/${params.id}`
);
if (!response.ok) {
throw new Error("Aktivita nenájdená");
}
const data = await response.json();
setActivity(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleJoin = async () => {
if (!activity) return;
setJoining(true);
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/activities/${activity.id}/join`,
{
method: "POST",
credentials: "include",
}
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Chyba pri prihlásení");
}
// Refresh activity data
await fetchActivity();
} catch (err: any) {
alert(err.message);
} finally {
setJoining(false);
}
};
const handleLeave = async () => {
if (!activity) return;
setLeaving(true);
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/activities/${activity.id}/join`,
{
method: "DELETE",
credentials: "include",
}
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Chyba pri odhlásení");
}
// Refresh activity data
await fetchActivity();
} catch (err: any) {
alert(err.message);
} finally {
setLeaving(false);
}
};
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-5xl mx-auto">
<p className="text-center text-[color:var(--fluent-text-secondary)]">
Načítavam...
</p>
</div>
</div>
);
}
if (error || !activity) {
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-5xl mx-auto">
<Card>
<div className="text-center py-12">
<p className="text-2xl mb-4"></p>
<h3 className="text-xl font-semibold text-[color:var(--fluent-text)] mb-2">
{error || "Aktivita nenájdená"}
</h3>
<Link href="/activities">
<Button variant="primary" className="mt-4">
Späť na zoznam
</Button>
</Link>
</div>
</Card>
</div>
</div>
);
}
const date = new Date(activity.date);
const formattedDate = date.toLocaleDateString("sk-SK", {
day: "numeric",
month: "long",
year: "numeric",
});
const formattedTime = date.toLocaleTimeString("sk-SK", {
hour: "2-digit",
minute: "2-digit",
});
const isParticipating = activity.participations.some(
(p) => p.user.id === currentUserId
);
const isOrganizer = activity.organizer.id === currentUserId;
const canJoin =
!isParticipating &&
!isOrganizer &&
activity.status === "OPEN" &&
activity.currentParticipants < activity.maxParticipants;
const fillPercentage =
(activity.currentParticipants / activity.maxParticipants) * 100;
const statusInfo = statusLabels[activity.status] || statusLabels.OPEN;
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-5xl mx-auto">
{/* Back button */}
<Link href="/activities">
<Button variant="secondary" className="mb-6">
Späť na zoznam
</Button>
</Link>
{/* Header */}
<Card className="mb-6">
<CardContent>
<div className="flex justify-between items-start mb-4">
<div className="flex-1">
<h1 className="text-4xl font-bold text-[color:var(--fluent-text)] mb-2">
{activity.title}
</h1>
<p className="text-xl text-[color:var(--fluent-text-secondary)]">
{sportTypeLabels[activity.sportType] || activity.sportType}
</p>
</div>
<span
className={`px-4 py-2 text-sm font-medium text-white rounded-full ${statusInfo.color}`}
>
{statusInfo.label}
</span>
</div>
{/* Action buttons */}
<div className="flex gap-3 mt-6">
{canJoin && (
<Button
variant="primary"
onClick={handleJoin}
disabled={joining}
>
{joining ? "Prihlasovanie..." : "✓ Prihlásiť sa"}
</Button>
)}
{isParticipating && !isOrganizer && (
<Button
variant="secondary"
onClick={handleLeave}
disabled={leaving}
>
{leaving ? "Odhlasovanie..." : "✗ Odhlásiť sa"}
</Button>
)}
{isOrganizer && (
<span className="px-4 py-2 text-sm font-medium bg-[color:var(--fluent-accent)]/10 text-[color:var(--fluent-accent)] rounded-lg">
👤 Organizátor
</span>
)}
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main info */}
<div className="lg:col-span-2 space-y-6">
{/* Details */}
<Card>
<CardHeader>
<CardTitle>Informácie o aktivite</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<p className="text-sm text-[color:var(--fluent-text-secondary)] mb-1">
Dátum a čas
</p>
<p className="text-lg text-[color:var(--fluent-text)]">
📅 {formattedDate} o {formattedTime}
</p>
</div>
<div>
<p className="text-sm text-[color:var(--fluent-text-secondary)] mb-1">
Dĺžka trvania
</p>
<p className="text-lg text-[color:var(--fluent-text)]">
🕐 {activity.duration} minút
</p>
</div>
<div>
<p className="text-sm text-[color:var(--fluent-text-secondary)] mb-1">
Športovisko
</p>
<p className="text-lg text-[color:var(--fluent-text)]">
📍 {activity.venue.name}
</p>
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
{activity.venue.address}, {activity.venue.city}
</p>
</div>
<div>
<p className="text-sm text-[color:var(--fluent-text-secondary)] mb-1">
Úroveň hráčov
</p>
<span className="inline-block px-3 py-1 text-sm font-medium bg-[color:var(--fluent-accent)]/10 text-[color:var(--fluent-accent)] rounded-full">
{skillLevelLabels[activity.skillLevel] ||
activity.skillLevel}
</span>
</div>
{activity.description && (
<div>
<p className="text-sm text-[color:var(--fluent-text-secondary)] mb-1">
Popis
</p>
<p className="text-[color:var(--fluent-text)]">
{activity.description}
</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Map */}
{activity.venue.latitude && activity.venue.longitude && (
<Card>
<CardHeader>
<CardTitle>Mapa</CardTitle>
</CardHeader>
<CardContent>
<div className="aspect-video w-full rounded-lg overflow-hidden">
<iframe
width="100%"
height="100%"
frameBorder="0"
style={{ border: 0 }}
src={`https://www.google.com/maps/embed/v1/place?key=AIzaSyBFw0Qbyq9zTFTd-tUY6dZWTgaQzuU17R8&q=${activity.venue.latitude},${activity.venue.longitude}&zoom=15`}
allowFullScreen
></iframe>
</div>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Participants progress */}
<Card>
<CardHeader>
<CardTitle>Obsadenosť</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-[color:var(--fluent-text-secondary)]">
Účastníci
</span>
<span className="font-semibold text-[color:var(--fluent-text)]">
{activity.currentParticipants}/{activity.maxParticipants}
</span>
</div>
<div className="w-full bg-[color:var(--fluent-surface-secondary)] rounded-full h-3 overflow-hidden">
<div
className="h-full bg-[color:var(--fluent-accent)] transition-all duration-300"
style={{ width: `${fillPercentage}%` }}
></div>
</div>
<p className="text-xs text-[color:var(--fluent-text-secondary)]">
{activity.maxParticipants - activity.currentParticipants}{" "}
voľných miest
</p>
</div>
</CardContent>
</Card>
{/* Organizer */}
<Card>
<CardHeader>
<CardTitle>Organizátor</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-[color:var(--fluent-accent)]/20 flex items-center justify-center text-[color:var(--fluent-accent)] font-bold">
{activity.organizer.name.charAt(0).toUpperCase()}
</div>
<div>
<p className="font-semibold text-[color:var(--fluent-text)]">
{activity.organizer.name}
</p>
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
{activity.organizer.email}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Participants list */}
<Card>
<CardHeader>
<CardTitle>
Účastníci ({activity.participations.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{activity.participations.map((participation) => (
<div
key={participation.id}
className="flex items-center gap-3"
>
<div className="w-10 h-10 rounded-full bg-[color:var(--fluent-accent)]/20 flex items-center justify-center text-[color:var(--fluent-accent)] font-bold text-sm">
{participation.user.name.charAt(0).toUpperCase()}
</div>
<p className="text-sm text-[color:var(--fluent-text)]">
{participation.user.name}
{participation.user.id === activity.organizer.id && (
<span className="ml-2 text-xs text-[color:var(--fluent-accent)]">
(Organizátor)
</span>
)}
</p>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,364 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
interface Venue {
id: string;
name: string;
address: string;
city: string;
sportTypes: string[];
}
const sportTypes = [
{ 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: "Posilňovňa" },
{ value: "OTHER", label: "Iné" },
];
const skillLevels = [
{ value: "BEGINNER", label: "Začiatočník" },
{ value: "INTERMEDIATE", label: "Mierne pokročilý" },
{ value: "ADVANCED", label: "Pokročilý" },
{ value: "EXPERT", label: "Expert" },
];
export default function CreateActivityPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [venues, setVenues] = useState<Venue[]>([]);
const [loadingVenues, setLoadingVenues] = useState(true);
const [error, setError] = useState("");
const [formData, setFormData] = useState({
title: "",
description: "",
sportType: "FOOTBALL",
skillLevel: "INTERMEDIATE",
date: "",
time: "",
duration: 90,
maxParticipants: 10,
venueId: "",
isPublic: true,
});
useEffect(() => {
fetchVenues();
}, []);
const fetchVenues = async () => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/venues`
);
if (response.ok) {
const data = await response.json();
setVenues(data);
if (data.length > 0) {
setFormData((prev) => ({ ...prev, venueId: data[0].id }));
}
}
} catch (err) {
console.error("Error fetching venues:", err);
} finally {
setLoadingVenues(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
// Combine date and time
const dateTime = new Date(`${formData.date}T${formData.time}`);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/activities`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
...formData,
date: dateTime.toISOString(),
}),
}
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Chyba pri vytváraní aktivity");
}
const activity = await response.json();
router.push(`/activities/${activity.id}`);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>
) => {
const { name, value, type } = e.target;
setFormData((prev) => ({
...prev,
[name]:
type === "checkbox"
? (e.target as HTMLInputElement).checked
: type === "number"
? Number(value)
: value,
}));
};
if (loadingVenues) {
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-3xl mx-auto">
<p className="text-center text-[color:var(--fluent-text-secondary)]">
Načítavam...
</p>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-3xl mx-auto">
<h1 className="text-4xl font-bold mb-8 text-[color:var(--fluent-text)]">
Vytvoriť novú aktivitu
</h1>
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/50 rounded-lg text-red-500">
{error}
</div>
)}
<Card>
<form onSubmit={handleSubmit}>
<CardContent>
{/* Názov */}
<div className="mb-6">
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
Názov aktivity *
</label>
<Input
type="text"
name="title"
value={formData.title}
onChange={handleChange}
required
minLength={3}
maxLength={100}
placeholder="napr. Futbal v parku"
/>
</div>
{/* Šport */}
<div className="mb-6">
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
Typ športu *
</label>
<select
name="sportType"
value={formData.sportType}
onChange={handleChange}
required
className="w-full px-4 py-2.5 bg-[color:var(--fluent-surface-secondary)] border border-[color:var(--fluent-border)] rounded-lg text-[color:var(--fluent-text)] focus:outline-none focus:ring-2 focus:ring-[color:var(--fluent-accent)]"
>
{sportTypes.map((sport) => (
<option key={sport.value} value={sport.value}>
{sport.label}
</option>
))}
</select>
</div>
{/* Dátum a čas */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div>
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
Dátum *
</label>
<Input
type="date"
name="date"
value={formData.date}
onChange={handleChange}
required
min={new Date().toISOString().split("T")[0]}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
Čas *
</label>
<Input
type="time"
name="time"
value={formData.time}
onChange={handleChange}
required
/>
</div>
</div>
{/* Dĺžka trvania a max účastníci */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div>
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
Dĺžka trvania (minúty) *
</label>
<Input
type="number"
name="duration"
value={formData.duration}
onChange={handleChange}
required
min={15}
max={480}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
Max počet hráčov *
</label>
<Input
type="number"
name="maxParticipants"
value={formData.maxParticipants}
onChange={handleChange}
required
min={2}
max={50}
/>
</div>
</div>
{/* Športovisko */}
<div className="mb-6">
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
Športovisko *
</label>
{venues.length === 0 ? (
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
Žiadne športoviská nie dostupné. Kontaktujte
administrátora.
</p>
) : (
<select
name="venueId"
value={formData.venueId}
onChange={handleChange}
required
className="w-full px-4 py-2.5 bg-[color:var(--fluent-surface-secondary)] border border-[color:var(--fluent-border)] rounded-lg text-[color:var(--fluent-text)] focus:outline-none focus:ring-2 focus:ring-[color:var(--fluent-accent)]"
>
{venues.map((venue) => (
<option key={venue.id} value={venue.id}>
{venue.name} - {venue.city}
</option>
))}
</select>
)}
</div>
{/* Úroveň */}
<div className="mb-6">
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
Úroveň hráčov *
</label>
<select
name="skillLevel"
value={formData.skillLevel}
onChange={handleChange}
required
className="w-full px-4 py-2.5 bg-[color:var(--fluent-surface-secondary)] border border-[color:var(--fluent-border)] rounded-lg text-[color:var(--fluent-text)] focus:outline-none focus:ring-2 focus:ring-[color:var(--fluent-accent)]"
>
{skillLevels.map((level) => (
<option key={level.value} value={level.value}>
{level.label}
</option>
))}
</select>
</div>
{/* Popis */}
<div className="mb-6">
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
Popis (voliteľné)
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={4}
placeholder="Pridajte podrobnosti o aktivite..."
className="w-full px-4 py-2.5 bg-[color:var(--fluent-surface-secondary)] border border-[color:var(--fluent-border)] rounded-lg text-[color:var(--fluent-text)] focus:outline-none focus:ring-2 focus:ring-[color:var(--fluent-accent)] resize-none"
/>
</div>
{/* Public/Private */}
<div className="mb-6">
<label className="flex items-center space-x-3 cursor-pointer">
<input
type="checkbox"
name="isPublic"
checked={formData.isPublic}
onChange={handleChange}
className="w-5 h-5 rounded border-[color:var(--fluent-border)] text-[color:var(--fluent-accent)] focus:ring-2 focus:ring-[color:var(--fluent-accent)]"
/>
<span className="text-sm text-[color:var(--fluent-text)]">
Verejná aktivita (viditeľná pre všetkých)
</span>
</label>
</div>
{/* Buttons */}
<div className="flex gap-4 justify-end">
<Button
type="button"
variant="secondary"
onClick={() => router.back()}
disabled={loading}
>
Zrušiť
</Button>
<Button
type="submit"
variant="primary"
disabled={loading || venues.length === 0}
>
{loading ? "Vytváranie..." : "Vytvoriť aktivitu"}
</Button>
</div>
</CardContent>
</form>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,237 @@
"use client";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/Button";
import { Card } 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: any[];
}
const sportTypeLabels: 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: "💪 Posilňovňa",
OTHER: "🎯 Iné",
};
const skillLevelLabels: Record<string, string> = {
BEGINNER: "Začiatočník",
INTERMEDIATE: "Mierne pokročilý",
ADVANCED: "Pokročilý",
EXPERT: "Expert",
};
const statusLabels: Record<string, { label: string; color: string }> = {
OPEN: { label: "Otvorená", color: "text-green-500" },
FULL: { label: "Plná", color: "text-orange-500" },
CANCELLED: { label: "Zrušená", color: "text-red-500" },
COMPLETED: { label: "Ukončená", color: "text-gray-500" },
};
function ActivityCard({ activity }: { activity: Activity }) {
const date = new Date(activity.date);
const formattedDate = date.toLocaleDateString("sk-SK", {
day: "numeric",
month: "long",
year: "numeric",
});
const formattedTime = date.toLocaleTimeString("sk-SK", {
hour: "2-digit",
minute: "2-digit",
});
const freeSpots = activity.maxParticipants - activity.currentParticipants;
const statusInfo = statusLabels[activity.status] || statusLabels.OPEN;
return (
<Link href={`/activities/${activity.id}`}>
<Card hover className="h-full">
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex justify-between items-start mb-4">
<div className="flex-1">
<h3 className="text-xl font-bold text-[color:var(--fluent-text)] mb-1">
{activity.title}
</h3>
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
{sportTypeLabels[activity.sportType] || activity.sportType}
</p>
</div>
<span className={`text-sm font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
{/* Date & Time */}
<div className="mb-3 space-y-1">
<p className="text-sm text-[color:var(--fluent-text)]">
📅 {formattedDate}
</p>
<p className="text-sm text-[color:var(--fluent-text)]">
🕐 {formattedTime} ({activity.duration} min)
</p>
</div>
{/* Location */}
<div className="mb-3">
<p className="text-sm text-[color:var(--fluent-text)]">
📍 {activity.venue.name}, {activity.venue.city}
</p>
</div>
{/* Skill Level */}
<div className="mb-3">
<span className="inline-block px-3 py-1 text-xs font-medium bg-[color:var(--fluent-accent)]/10 text-[color:var(--fluent-accent)] rounded-full">
{skillLevelLabels[activity.skillLevel] || activity.skillLevel}
</span>
</div>
{/* Participants */}
<div className="mt-auto pt-3 border-t border-[color:var(--fluent-border)]">
<div className="flex justify-between items-center">
<span className="text-sm text-[color:var(--fluent-text-secondary)]">
Účastníci: {activity.currentParticipants}/
{activity.maxParticipants}
</span>
<span className="text-sm font-medium text-[color:var(--fluent-accent)]">
{freeSpots > 0 ? `${freeSpots} voľných miest` : "Obsadené"}
</span>
</div>
</div>
</div>
</Card>
</Link>
);
}
function ActivitySkeleton() {
return (
<Card>
<div className="animate-pulse">
<div className="h-6 bg-[color:var(--fluent-border)] rounded w-3/4 mb-4"></div>
<div className="h-4 bg-[color:var(--fluent-border)] rounded w-1/2 mb-3"></div>
<div className="h-4 bg-[color:var(--fluent-border)] rounded w-2/3 mb-3"></div>
<div className="h-4 bg-[color:var(--fluent-border)] rounded w-1/2"></div>
</div>
</Card>
);
}
export default function ActivitiesPage() {
const [activities, setActivities] = useState<Activity[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
fetchActivities();
}, []);
const fetchActivities = async () => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/activities`
);
if (!response.ok) {
throw new Error("Chyba pri načítaní aktivít");
}
const data = await response.json();
setActivities(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-4xl font-bold text-[color:var(--fluent-text)] mb-2">
Športové aktivity
</h1>
<p className="text-[color:var(--fluent-text-secondary)]">
Nájdi si aktivitu a pripoj sa k ostatným
</p>
</div>
<Link href="/activities/create">
<Button variant="primary">+ Vytvoriť aktivitu</Button>
</Link>
</div>
{/* Error */}
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/50 rounded-lg text-red-500">
{error}
</div>
)}
{/* Loading */}
{loading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<ActivitySkeleton key={i} />
))}
</div>
)}
{/* Empty state */}
{!loading && activities.length === 0 && (
<Card>
<div className="text-center py-12">
<p className="text-2xl mb-4">🏀</p>
<h3 className="text-xl font-semibold text-[color:var(--fluent-text)] mb-2">
Žiadne aktivity
</h3>
<p className="text-[color:var(--fluent-text-secondary)] mb-6">
Zatiaľ nie vytvorené žiadne aktivity. Buď prvý!
</p>
<Link href="/activities/create">
<Button variant="primary">Vytvoriť aktivitu</Button>
</Link>
</div>
</Card>
)}
{/* Activities grid */}
{!loading && activities.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{activities.map((activity) => (
<ActivityCard key={activity.id} activity={activity} />
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,156 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
setSuccess(false);
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/auth/forgot-password`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
}
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Chyba pri odosielaní emailu");
}
setSuccess(true);
setEmail("");
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<Card>
<CardHeader>
<div className="text-center mb-6">
<div className="text-5xl mb-4">🔒</div>
<CardTitle className="text-3xl">Zabudnuté heslo?</CardTitle>
<p className="text-[color:var(--fluent-text-secondary)] mt-2">
Žiadny problém! Zadaj svoj email a pošleme ti link na reset
hesla.
</p>
</div>
</CardHeader>
<CardContent>
{success ? (
<div className="space-y-6">
<div className="p-4 bg-green-500/10 border border-green-500/50 rounded-lg">
<div className="flex items-start gap-3">
<div className="text-2xl"></div>
<div>
<h3 className="font-semibold text-green-600 mb-1">
Email odoslaný!
</h3>
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
Ak email existuje v našej databáze, poslali sme ti
inštrukcie na reset hesla. Skontroluj si emailovú
schránku.
</p>
</div>
</div>
</div>
<div className="p-4 bg-[color:var(--fluent-surface-secondary)] rounded-lg">
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
<strong>💡 Tip:</strong> Nezabudni skontrolovať aj SPAM
priečinok. Link je platný iba 1 hodinu.
</p>
</div>
<div className="text-center pt-4">
<Link href="/auth/signin">
<Button variant="secondary" className="w-full">
Späť na prihlásenie
</Button>
</Link>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/50 rounded-lg text-red-500">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
Email *
</label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="tvoj@email.sk"
required
/>
<p className="text-xs text-[color:var(--fluent-text-secondary)] mt-2">
Zadaj email, ktorý si použil pri registrácii
</p>
</div>
<Button
type="submit"
variant="primary"
disabled={loading}
className="w-full"
>
{loading ? "Odosiela sa..." : "📧 Odoslať reset link"}
</Button>
<div className="text-center pt-4">
<Link href="/auth/signin">
<Button variant="secondary" className="w-full">
Späť na prihlásenie
</Button>
</Link>
</div>
</form>
)}
</CardContent>
</Card>
<div className="mt-6 text-center">
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
Nemáš ešte účet?{" "}
<Link
href="/auth/signup"
className="text-[color:var(--fluent-accent)] hover:underline font-medium"
>
Zaregistruj sa
</Link>
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,197 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
export default function ResetPasswordPage() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!token) {
setError("Chýbajúci reset token. Prosím, požiadaj o nový reset link.");
}
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
setSuccess(false);
if (password !== confirmPassword) {
setError("Heslá sa nezhodujú");
setLoading(false);
return;
}
if (password.length < 8) {
setError("Heslo musí mať minimálne 8 znakov");
setLoading(false);
return;
}
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/auth/reset-password`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token, password }),
}
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Chyba pri resete hesla");
}
setSuccess(true);
setTimeout(() => {
router.push("/auth/signin");
}, 3000);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (!token) {
return (
<div className="min-h-screen flex items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<Card>
<CardContent className="py-12">
<div className="text-center">
<div className="text-5xl mb-4"></div>
<h3 className="text-xl font-semibold text-[color:var(--fluent-text)] mb-2">
Neplatný link
</h3>
<p className="text-[color:var(--fluent-text-secondary)] mb-6">
Tento reset link je neplatný alebo expiroval.
</p>
<Link href="/auth/forgot-password">
<Button variant="primary">Požiadať o nový link</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<Card>
<CardHeader>
<div className="text-center mb-6">
<div className="text-5xl mb-4">🔑</div>
<CardTitle className="text-3xl">Nové heslo</CardTitle>
<p className="text-[color:var(--fluent-text-secondary)] mt-2">
Zadaj svoje nové heslo
</p>
</div>
</CardHeader>
<CardContent>
{success ? (
<div className="space-y-6">
<div className="p-4 bg-green-500/10 border border-green-500/50 rounded-lg">
<div className="flex items-start gap-3">
<div className="text-2xl"></div>
<div>
<h3 className="font-semibold text-green-600 mb-1">
Heslo zmenené!
</h3>
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
Tvoje heslo bolo úspešne zmenené. Budeš presmerovaný na
prihlasovaciu stránku...
</p>
</div>
</div>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/50 rounded-lg text-red-500">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
Nové heslo *
</label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Minimálne 8 znakov"
required
minLength={8}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
Potvrdenie hesla *
</label>
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Zopakuj heslo"
required
minLength={8}
/>
</div>
<div className="p-4 bg-[color:var(--fluent-surface-secondary)] rounded-lg">
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
<strong>💡 Tip:</strong> Použite silné heslo s kombináciou
písmen, čísiel a špeciálnych znakov.
</p>
</div>
<Button
type="submit"
variant="primary"
disabled={loading}
className="w-full"
>
{loading ? "Ukladá sa..." : "🔒 Zmeniť heslo"}
</Button>
<div className="text-center pt-4">
<Link href="/auth/signin">
<Button variant="secondary" className="w-full">
Späť na prihlásenie
</Button>
</Link>
</div>
</form>
)}
</CardContent>
</Card>
</div>
</div>
);
}

10280
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +0,0 @@
{
"name": "sportbuddy-monorepo",
"version": "1.0.0",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "concurrently \"npm run dev -w @sportbuddy/backend\" \"npm run dev -w @sportbuddy/frontend\"",
"build": "npm run build -w @sportbuddy/backend && npm run build -w @sportbuddy/frontend",
"backend": "npm run dev -w @sportbuddy/backend",
"frontend": "npm run dev -w @sportbuddy/frontend",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down",
"docker:rebuild": "docker-compose down && docker-compose build --no-cache && docker-compose up -d"
},
"devDependencies": {
"concurrently": "^9.1.0"
}
}

View File

@ -5,6 +5,6 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"zod": "^4.1.12"
"zod": "latest"
}
}

View File

@ -1,42 +0,0 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
},
"target": "ES2017",
"types": []
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}