US-019, US-003, US-004, US-005 - KB
This commit is contained in:
parent
081d285f34
commit
29829adb1d
@ -16,9 +16,17 @@ BETTER_AUTH_SECRET="change-this-to-a-random-secret-in-production-min-32-chars"
|
|||||||
BETTER_AUTH_URL="http://localhost:3001"
|
BETTER_AUTH_URL="http://localhost:3001"
|
||||||
|
|
||||||
# Frontend Configuration
|
# 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
|
# API URL for frontend to communicate with backend
|
||||||
NEXT_PUBLIC_API_URL="http://localhost:3001"
|
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)
|
# OAuth Providers (Optional - configure only if you want to enable OAuth)
|
||||||
# Google OAuth: https://console.cloud.google.com/apis/credentials
|
# Google OAuth: https://console.cloud.google.com/apis/credentials
|
||||||
GOOGLE_CLIENT_ID=""
|
GOOGLE_CLIENT_ID=""
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,6 +8,9 @@ npm-debug.log*
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
183
README.md
183
README.md
@ -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)
|
**Frontend:** Next.js (latest), React (latest), TypeScript (latest), Tailwind CSS (latest)
|
||||||
**Backend:** Next.js API Routes, Prisma ORM (latest), Better Auth (latest), PostgreSQL (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
|
git clone git@git.kemt.fei.tuke.sk:kb159dr/SportBuddy.git
|
||||||
cd sportbuddy
|
cd sportbuddy
|
||||||
|
|
||||||
# 2. Skopíruj environment variables (DÔLEŽITÉ!)
|
# 2. Skopíruj premenné prostredia (DÔLEŽITÉ!)
|
||||||
cp .env.example .env
|
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
|
docker-compose up -d
|
||||||
|
|
||||||
# 4. Otvor aplikáciu v prehliadači
|
# 4. Otvor aplikáciu v prehliadači
|
||||||
@ -38,7 +37,7 @@ docker-compose up -d
|
|||||||
# Backend API: http://localhost:3001/api
|
# 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)**
|
**⚠️ 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)
|
- `postgres:alpine` (databáza)
|
||||||
- `node:alpine` (Node.js runtime)
|
- `node:alpine` (Node.js prostredie)
|
||||||
|
|
||||||
2. **Backend automaticky:**
|
2. **Backend automaticky:**
|
||||||
- Nainštaluje npm dependencies
|
- Nainštaluje npm závislosti
|
||||||
- Vygeneruje Prisma Client
|
- Vygeneruje Prisma klienta
|
||||||
- Vytvorí databázové tabuľky (`prisma db push`)
|
- 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:**
|
3. **Frontend automaticky:**
|
||||||
- Nainštaluje npm dependencies
|
- Nainštaluje npm závislosti
|
||||||
- Spustí development server na porte **3000**
|
- Spustí vývojársky server na porte **3000**
|
||||||
|
|
||||||
4. **PostgreSQL:**
|
4. **PostgreSQL:**
|
||||||
- Vytvorí databázu `sportbuddy`
|
- Vytvorí databázu `sportbuddy`
|
||||||
- Beží na porte **5432**
|
- 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
|
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
|
```bash
|
||||||
# Spustenie všetkých služieb
|
# Spustenie všetkých služieb
|
||||||
docker-compose up -d
|
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
|
||||||
docker-compose logs -f backend # len backend
|
docker-compose logs -f backend # len backend
|
||||||
docker-compose logs -f frontend # len frontend
|
docker-compose logs -f frontend # len frontend
|
||||||
|
|
||||||
# Rebuild po zmene Dockerfile
|
# Opätovné zostavenie po zmene Dockerfile
|
||||||
docker-compose up -d --build
|
docker-compose up -d --build
|
||||||
|
|
||||||
# Zastavenie služieb
|
# Zastavenie služieb
|
||||||
@ -98,11 +97,11 @@ docker-compose down
|
|||||||
# Vyčistenie všetkého (vrátane databázy!)
|
# Vyčistenie všetkého (vrátane databázy!)
|
||||||
docker-compose down -v
|
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 backend sh
|
||||||
docker-compose exec frontend 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
|
docker-compose exec backend npx prisma studio
|
||||||
# Otvor: http://localhost:5555
|
# Otvor: http://localhost:5555
|
||||||
```
|
```
|
||||||
@ -114,41 +113,83 @@ docker-compose exec backend npx prisma studio
|
|||||||
```
|
```
|
||||||
sportbuddy/
|
sportbuddy/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── backend/ # Backend API (Next.js + Prisma)
|
│ ├── backend/ # Backend API (Next.js + Prisma)
|
||||||
│ │ ├── src/
|
|
||||||
│ │ │ ├── app/api/ # API endpoints
|
|
||||||
│ │ │ └── lib/ # Server utilities (auth, prisma)
|
|
||||||
│ │ ├── prisma/
|
│ │ ├── prisma/
|
||||||
│ │ │ └── schema.prisma # Databázová schéma
|
│ │ │ ├── schema.prisma # Databázová schéma
|
||||||
│ │ ├── Dockerfile # Unifikovaný Docker image (dev + prod)
|
│ │ │ └── seed.ts # Počiatočné dáta do databázy (športoviská)
|
||||||
│ │ └── package.json
|
│ │ ├── 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)
|
│ └── frontend/ # Frontend UI (Next.js + React)
|
||||||
|
│ ├── public/
|
||||||
|
│ │ ├── manifest.json # PWA manifest
|
||||||
|
│ │ └── sw.js # Service Worker
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── app/ # Next.js App Router stránky
|
│ │ ├── app/ # Next.js App Router stránky
|
||||||
│ │ ├── components/ # React komponenty
|
│ │ │ ├── auth/ # Autentifikačné stránky (prihlásenie, registrácia)
|
||||||
│ │ └── contexts/ # React Context (theme, atď.)
|
│ │ │ ├── dashboard/ # Stránka dashboardu
|
||||||
│ ├── Dockerfile # Unifikovaný Docker image (dev + prod)
|
│ │ │ ├── profile/ # Stránky profilu
|
||||||
│ └── package.json
|
│ │ │ ├── globals.css # Globálne štýly
|
||||||
|
│ │ │ ├── layout.tsx # Hlavné rozloženie
|
||||||
|
│ │ │ └── page.tsx # Domovská stránka
|
||||||
|
│ │ ├── components/ # React komponenty
|
||||||
|
│ │ │ ├── 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/
|
├── packages/
|
||||||
│ └── shared/ # Zdieľané TypeScript typy
|
│ └── 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
|
├── .dockerignore # Dockerignore
|
||||||
├── .env # Environment variables (lokálne, NIE v Gite!)
|
├── .env # Premenné prostredia (len lokálne, nie v Gite!)
|
||||||
├── .env.example # Template (commituj do Gitu)
|
├── .env.example # Šablóna premenných prostredia (commituj toto)
|
||||||
├── README.md # Návod na používanie
|
├── .gitignore # Gitignore
|
||||||
└── USER_STORIES.md # Prehľad user stories a taskov
|
├── 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)
|
### 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:**
|
**Pre nových vývojárov:**
|
||||||
```bash
|
```bash
|
||||||
@ -167,6 +208,8 @@ POSTGRES_DB=sportbuddy
|
|||||||
DATABASE_URL="postgresql://sportbuddy:sportbuddy123@postgres:5432/sportbuddy"
|
DATABASE_URL="postgresql://sportbuddy:sportbuddy123@postgres:5432/sportbuddy"
|
||||||
BETTER_AUTH_SECRET="change-this-in-production"
|
BETTER_AUTH_SECRET="change-this-in-production"
|
||||||
BETTER_AUTH_URL="http://localhost:3001"
|
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
|
# Frontend
|
||||||
NEXT_PUBLIC_API_URL="http://localhost:3001"
|
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
|
### 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)
|
### 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:
|
# → Potom spusti:
|
||||||
docker-compose exec backend npx prisma db push
|
docker-compose exec backend npx prisma db push
|
||||||
|
|
||||||
# Otvor Prisma Studio (GUI)
|
# Otvor Prisma Studio (grafické rozhranie)
|
||||||
docker-compose exec backend npx prisma studio
|
docker-compose exec backend npx prisma studio
|
||||||
|
|
||||||
# Reset databázy (POZOR: vymaže všetky dáta!)
|
# Reset databázy (POZOR: vymaže všetky dáta!)
|
||||||
docker-compose exec backend npx prisma db push --force-reset
|
docker-compose exec backend npx prisma db push --force-reset
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Debugging
|
### 3. Ladenie
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend logy (API requesty, errory)
|
# Záznamy backendu (API požiadavky, chyby)
|
||||||
docker-compose logs -f backend
|
docker-compose logs -f backend
|
||||||
|
|
||||||
# Frontend logy (build output, errors)
|
# Záznamy frontendu (výstup zostavenia, chyby)
|
||||||
docker-compose logs -f frontend
|
docker-compose logs -f frontend
|
||||||
|
|
||||||
# Databáza logy
|
# Záznamy databázy
|
||||||
docker-compose logs -f postgres
|
docker-compose logs -f postgres
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Pridávanie nových dependencies
|
### 4. Pridávanie nových závislostí
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
# 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)
|
- **Vývojársky režim** (používa docker-compose.yml)
|
||||||
- Hot reload cez volume mounts
|
- Automatické načítanie zmien cez pripojenie zväzkov
|
||||||
- `npm run dev`
|
- `npm run dev`
|
||||||
- Debug-friendly
|
- Vhodné pre ladenie
|
||||||
|
|
||||||
- **Production stage** (pre nasadenie)
|
- **Produkčný režim** (pre nasadenie)
|
||||||
- Optimalizovaný build
|
- Optimalizované zostavenie
|
||||||
- Multi-stage image (menší size)
|
- Viacstupňový obraz (menšia veľkosť)
|
||||||
- `npm run build`
|
- `npm run build`
|
||||||
|
|
||||||
### Ako to funguje?
|
### 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:
|
### ✅ Commituj:
|
||||||
- Všetok kód v `apps/*/src/`
|
- 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)
|
- `prisma/schema.prisma` (po zmene schémy)
|
||||||
- `Dockerfile`, `docker-compose.yml`
|
- `Dockerfile`, `docker-compose.yml`
|
||||||
- `.env.example` (template bez secrets)
|
- `.env.example` (template bez secrets)
|
||||||
|
|
||||||
### ❌ Necommituj:
|
### ❌ Necommituj:
|
||||||
- `node_modules/` (automaticky ignorované)
|
- `node_modules/` (automaticky ignorované)
|
||||||
- `.next/` (build artefakty)
|
- `.next/` (výsledky zostavenia obrazov)
|
||||||
- `.env` (obsahuje secrets - NIKDY necommituj!)
|
- `.env` (obsahuje tajné kľúče - NIKDY necommituj!)
|
||||||
- `.vscode/`, `.idea/` (IDE nastavenia)
|
- `.vscode/`, `.idea/` (nastavenia IDE)
|
||||||
|
|
||||||
### 🔄 Po každom git pull:
|
### 🔄 Po každom git pull:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ak niekto zmenil Dockerfile alebo dependencies
|
# Ak niekto zmenil Dockerfile alebo závislosti
|
||||||
docker-compose down
|
docker-compose down
|
||||||
docker-compose up -d --build
|
docker-compose up -d --build
|
||||||
```
|
```
|
||||||
@ -295,10 +338,10 @@ docker-compose up -d --build
|
|||||||
### 🐛 Keď niečo nefunguje:
|
### 🐛 Keď niečo nefunguje:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Skús restart
|
# 1. Skús reštart
|
||||||
docker-compose restart
|
docker-compose restart
|
||||||
|
|
||||||
# 2. Skús rebuild
|
# 2. Skús opätovné zostavenie
|
||||||
docker-compose up -d --build
|
docker-compose up -d --build
|
||||||
|
|
||||||
# 3. Vyčisti všetko a začni odznova
|
# 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ý
|
cp .env.example .env # Obnov .env ak bol zmazaný
|
||||||
docker-compose up -d --build
|
docker-compose up -d --build
|
||||||
|
|
||||||
# 4. Skontroluj logy
|
# 4. Skontroluj záznamy
|
||||||
docker-compose logs -f
|
docker-compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -314,8 +357,8 @@ docker-compose logs -f
|
|||||||
|
|
||||||
## Ďalšie kroky
|
## Ďalšie kroky
|
||||||
|
|
||||||
1. **Prečítaj si** [USER_STORIES.md](USER_STORIES.md) - zoznam všetkých user stories a ich stav
|
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 task**
|
2. **Vyber si úlohu**
|
||||||
3. **Pozri databázovú schému** - `apps/backend/prisma/schema.prisma`
|
3. **Pozri databázovú schému** - `apps/backend/prisma/schema.prisma`
|
||||||
4. **Pozri API - backend** - `apps/backend/src/app/api/`
|
4. **Pozri API - backend** - `apps/backend/src/app/api/`
|
||||||
|
|
||||||
|
|||||||
127
USER_STORIES.md
127
USER_STORIES.md
@ -20,7 +20,7 @@ aby som mohol používať aplikáciu
|
|||||||
- ✅ Registračný formulár (/register)
|
- ✅ Registračný formulár (/register)
|
||||||
- ✅ Prihlasovací formulár (/login)
|
- ✅ Prihlasovací formulár (/login)
|
||||||
- ✅ Validácia (email formát, heslo min 8 znakov)
|
- ✅ Validácia (email formát, heslo min 8 znakov)
|
||||||
- ✅ Hash hesla (bcrypt)
|
- ✅ Hash hesla (scrypt via Better Auth)
|
||||||
- ✅ Session management (localStorage)
|
- ✅ Session management (localStorage)
|
||||||
- ✅ Responzívny dizajn formulárov
|
- ✅ 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
|
## US-003: Vytvorenie aktivity
|
||||||
|
|
||||||
**Status:** 🔄 WIP (Work In Progress)
|
**Status:** ✅ HOTOVÉ
|
||||||
|
|
||||||
Ako používateľ
|
Ako používateľ
|
||||||
chcem vytvoriť novú športovú aktivitu
|
chcem vytvoriť novú športovú aktivitu
|
||||||
@ -91,26 +91,26 @@ aby som našiel spoluhráčov
|
|||||||
- ✅ API: POST /api/activities
|
- ✅ API: POST /api/activities
|
||||||
- ✅ Automatické pridanie tvorcu ako účastníka
|
- ✅ Automatické pridanie tvorcu ako účastníka
|
||||||
- ✅ Validácia (dátum v budúcnosti, cena >= 0)
|
- ✅ Validácia (dátum v budúcnosti, cena >= 0)
|
||||||
- ⏸️ Formulár na vytvorenie (/activities/create)
|
- ✅ Formulár na vytvorenie (/activities/create)
|
||||||
- ⏸️ Polia: názov, šport (dropdown), dátum, čas, miesto, max hráčov, úroveň, cena, popis
|
- ✅ Polia: názov, šport (dropdown), dátum, čas, miesto, max hráčov, úroveň, cena, popis
|
||||||
- ⏸️ React Hook Form + Zod validácia
|
- ✅ React Hook Form + Zod validácia
|
||||||
- ⏸️ Loading state pri submit
|
- ✅ Loading state pri submit
|
||||||
- ⏸️ Redirect na detail po vytvorení
|
- ✅ Redirect na detail po vytvorení
|
||||||
- ⏸️ Responzívny formulár
|
- ✅ Responzívny formulár
|
||||||
|
|
||||||
### Výsledné funkcie:
|
### Výsledné funkcie:
|
||||||
- ✅ API endpoint funguje
|
- ✅ API endpoint funguje
|
||||||
- ✅ Funkčná validácia na BE
|
- ✅ Funkčná validácia na BE
|
||||||
- ✅ Aktivita sa uloží do DB
|
- ✅ Aktivita sa uloží do DB
|
||||||
- ⏸️ Frontend formulár chýba
|
- ✅ Frontend formulár implementovaný a funkčný
|
||||||
|
- ✅ Automatické načítanie venues
|
||||||
**Poznámka:** API je hotové, ale chýba frontend formulár na vytvorenie aktivity.
|
- ✅ Validácia na FE a BE
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## US-004: Zoznam a detail aktivít
|
## US-004: Zoznam a detail aktivít
|
||||||
|
|
||||||
**Status:** 🔄 WIP (Work In Progress)
|
**Status:** ✅ HOTOVÉ
|
||||||
|
|
||||||
Ako používateľ
|
Ako používateľ
|
||||||
chcem vidieť zoznam aktivít a ich detail
|
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 (pagination 20/page)
|
||||||
- ✅ API: GET /api/activities/[id]
|
- ✅ API: GET /api/activities/[id]
|
||||||
- ✅ Filtrovanie podľa športu, mesta, statusu
|
- ✅ Filtrovanie podľa športu, mesta, statusu
|
||||||
- ⏸️ Stránka zoznamu (/activities)
|
- ✅ Stránka zoznamu (/activities)
|
||||||
- ⏸️ Card komponenta pre aktivitu
|
- ✅ Card komponenta pre aktivitu
|
||||||
- ⏸️ Zobrazenie: názov, šport, dátum, čas, miesto, voľné miesta
|
- ✅ Zobrazenie: názov, šport, dátum, čas, miesto, voľné miesta
|
||||||
- ⏸️ Loading skeleton
|
- ✅ Loading skeleton
|
||||||
- ⏸️ Empty state ("Žiadne aktivity")
|
- ✅ Empty state ("Žiadne aktivity")
|
||||||
- ⏸️ Detail stránka (/activities/[id])
|
- ✅ Detail stránka (/activities/[id])
|
||||||
- ⏸️ Kompletné info + mapa (Google Maps embed)
|
- ✅ Kompletné info + mapa (Google Maps embed)
|
||||||
- ⏸️ Zoznam účastníkov
|
- ✅ Zoznam účastníkov
|
||||||
- ⏸️ Progress bar obsadenosti
|
- ✅ Progress bar obsadenosti
|
||||||
- ⏸️ Responzívny grid/detail
|
- ✅ Responzívny grid/detail
|
||||||
|
|
||||||
### Výsledné funkcie:
|
### Výsledné funkcie:
|
||||||
- ✅ API endpoints fungujú
|
- ✅ API endpoints fungujú
|
||||||
- ⏸️ Zoznam aktivít (UI)
|
- ✅ Zoznam aktivít (UI) s kartami
|
||||||
- ⏸️ Detail aktivity (UI)
|
- ✅ Detail aktivity s kompletnou informáciou
|
||||||
- ⏸️ Pagination funguje
|
- ✅ Mapa športoviska
|
||||||
|
- ✅ Progress bar a zoznam účastníkov
|
||||||
**Poznámka:** Backend API je kompletný s filtrovaním, ale chýba celý frontend UI.
|
- ✅ Loading states a empty states
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## US-005: Prihlasovanie na aktivity
|
## US-005: Prihlasovanie na aktivity
|
||||||
|
|
||||||
**Status:** 🔄 WIP (Work In Progress)
|
**Status:** ✅ HOTOVÉ
|
||||||
|
|
||||||
Ako používateľ
|
Ako používateľ
|
||||||
chcem sa prihlásiť na aktivitu
|
chcem sa prihlásiť na aktivitu
|
||||||
@ -159,20 +159,20 @@ aby som rezervoval miesto
|
|||||||
- ✅ Kontrola voľnej kapacity
|
- ✅ Kontrola voľnej kapacity
|
||||||
- ✅ Kontrola duplicity (už prihlásený)
|
- ✅ Kontrola duplicity (už prihlásený)
|
||||||
- ✅ Aktualizácia počtu hráčov
|
- ✅ Aktualizácia počtu hráčov
|
||||||
- ⏸️ Tlačidlo "Prihlásiť sa" na detaile
|
- ✅ Tlačidlo "Prihlásiť sa" na detaile
|
||||||
- ⏸️ Tlačidlo "Odhlásiť sa" ak som prihlásený
|
- ✅ Tlačidlo "Odhlásiť sa" ak som prihlásený
|
||||||
- ⏸️ API: DELETE /api/activities/[id]/leave
|
- ✅ API: DELETE /api/activities/[id]/join
|
||||||
- ⏸️ Optimistic updates (TanStack Query)
|
- ✅ Optimistic updates (TanStack Query)
|
||||||
- ⏸️ Toast notifikácie
|
- ✅ Toast notifikácie
|
||||||
- ⏸️ Badge "Prihlásený" na karte aktivity
|
- ✅ Badge "Prihlásený" na karte aktivity
|
||||||
|
|
||||||
### Výsledné funkcie:
|
### Výsledné funkcie:
|
||||||
- ✅ Prihlásenie funguje (API)
|
- ✅ Prihlásenie funguje (API + UI)
|
||||||
- ⏸️ Odhlásenie funguje
|
- ✅ Odhlásenie funguje (API + UI)
|
||||||
- ✅ Počet hráčov sa aktualizuje
|
- ✅ Počet hráčov sa aktualizuje
|
||||||
- ⏸️ UI komponenty chýbajú
|
- ✅ UI komponenty implementované
|
||||||
|
- ✅ Automatický refresh dát po akcii
|
||||||
**Poznámka:** Join API je hotové, ale chýba leave endpoint a všetky UI elementy.
|
- ✅ Vizuálna indikácia stavu (organizátor/účastník)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -805,6 +805,55 @@ aby som hral s ľuďmi na mojej úrovni
|
|||||||
- ⏸️ User suggestions
|
- ⏸️ User suggestions
|
||||||
- ⏸️ Weekly digest
|
- ⏸️ 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
15
apps/backend/.dockerignore
Normal file
15
apps/backend/.dockerignore
Normal 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
|
||||||
@ -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=""
|
|
||||||
5272
apps/backend/package-lock.json
generated
5272
apps/backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,11 +12,15 @@
|
|||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:push": "prisma db push",
|
"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": {
|
"dependencies": {
|
||||||
|
"@getbrevo/brevo": "latest",
|
||||||
"@prisma/client": "latest",
|
"@prisma/client": "latest",
|
||||||
"bcryptjs": "latest",
|
|
||||||
"better-auth": "latest",
|
"better-auth": "latest",
|
||||||
"next": "latest",
|
"next": "latest",
|
||||||
"react": "latest",
|
"react": "latest",
|
||||||
@ -29,6 +33,7 @@
|
|||||||
"eslint": "latest",
|
"eslint": "latest",
|
||||||
"eslint-config-next": "latest",
|
"eslint-config-next": "latest",
|
||||||
"prisma": "latest",
|
"prisma": "latest",
|
||||||
|
"tsx": "latest",
|
||||||
"typescript": "latest"
|
"typescript": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,7 @@ model User {
|
|||||||
participations Participation[]
|
participations Participation[]
|
||||||
reviews Review[]
|
reviews Review[]
|
||||||
favoriteVenues VenueFavorite[]
|
favoriteVenues VenueFavorite[]
|
||||||
|
passwordResets PasswordReset[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile model for user details (separate from auth)
|
// Profile model for user details (separate from auth)
|
||||||
@ -89,6 +90,20 @@ model Verification {
|
|||||||
@@unique([identifier, value])
|
@@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
|
// Sport types enumeration
|
||||||
enum SportType {
|
enum SportType {
|
||||||
FOOTBALL
|
FOOTBALL
|
||||||
|
|||||||
97
apps/backend/prisma/seed.ts
Normal file
97
apps/backend/prisma/seed.ts
Normal 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();
|
||||||
|
});
|
||||||
@ -68,7 +68,7 @@ export async function POST(
|
|||||||
data: {
|
data: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
activityId: id,
|
activityId: id,
|
||||||
status: "confirmed",
|
status: "CONFIRMED",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,6 @@ export async function GET(
|
|||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
image: true,
|
image: true,
|
||||||
skillLevel: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
participations: {
|
participations: {
|
||||||
@ -31,7 +30,6 @@ export async function GET(
|
|||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
image: true,
|
image: true,
|
||||||
skillLevel: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -124,7 +124,7 @@ export async function POST(request: NextRequest) {
|
|||||||
data: {
|
data: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
activityId: activity.id,
|
activityId: activity.id,
|
||||||
status: "confirmed",
|
status: "CONFIRMED",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
73
apps/backend/src/app/api/auth/forgot-password/route.ts
Normal file
73
apps/backend/src/app/api/auth/forgot-password/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
apps/backend/src/app/api/auth/reset-password/route.ts
Normal file
108
apps/backend/src/app/api/auth/reset-password/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
186
apps/backend/src/lib/email.ts
Normal file
186
apps/backend/src/lib/email.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/frontend/.dockerignore
Normal file
15
apps/frontend/.dockerignore
Normal 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
|
||||||
@ -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"
|
|
||||||
8049
apps/frontend/package-lock.json
generated
8049
apps/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
464
apps/frontend/src/app/activities/[id]/page.tsx
Normal file
464
apps/frontend/src/app/activities/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
364
apps/frontend/src/app/activities/create/page.tsx
Normal file
364
apps/frontend/src/app/activities/create/page.tsx
Normal 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 sú 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
237
apps/frontend/src/app/activities/page.tsx
Normal file
237
apps/frontend/src/app/activities/page.tsx
Normal 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 sú 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
apps/frontend/src/app/auth/forgot-password/page.tsx
Normal file
156
apps/frontend/src/app/auth/forgot-password/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
apps/frontend/src/app/auth/reset-password/page.tsx
Normal file
197
apps/frontend/src/app/auth/reset-password/page.tsx
Normal 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
10280
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,6 +5,6 @@
|
|||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.1.12"
|
"zod": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user