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"
|
||||
|
||||
# Frontend Configuration
|
||||
# Frontend URL (used for password reset emails and external links)
|
||||
NEXT_PUBLIC_FRONTEND_URL="http://localhost:3000"
|
||||
|
||||
# API URL for frontend to communicate with backend
|
||||
NEXT_PUBLIC_API_URL="http://localhost:3001"
|
||||
|
||||
# Brevo Email Service (for password reset emails)
|
||||
# Get API key from: https://app.brevo.com/settings/keys/api
|
||||
# Leave empty for development mode (emails logged to console)
|
||||
BREVO_API_KEY=""
|
||||
|
||||
# OAuth Providers (Optional - configure only if you want to enable OAuth)
|
||||
# Google OAuth: https://console.cloud.google.com/apis/credentials
|
||||
GOOGLE_CLIENT_ID=""
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,6 +8,9 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
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)
|
||||
**Backend:** Next.js API Routes, Prisma ORM (latest), Better Auth (latest), PostgreSQL (latest)
|
||||
**DevOps:** Docker & Docker Compose, Node.js (alpine), npm Workspaces
|
||||
**DevOps:** Docker & Docker Compose, Node.js (alpine)
|
||||
|
||||
> **Poznámka:** Všetky verzie používajú latest Alpine Linux images a npm packages pre najnovšie stabilné vydania.
|
||||
> **Poznámka:** Všetky verzie používajú najnovšie Alpine Linux obrazy a npm balíčky pre najnovšie stabilné vydania.
|
||||
|
||||
---
|
||||
|
||||
@ -26,11 +26,10 @@ Moderná webová aplikácia pre športových nadšencov - hľadanie spoluhráčo
|
||||
git clone git@git.kemt.fei.tuke.sk:kb159dr/SportBuddy.git
|
||||
cd sportbuddy
|
||||
|
||||
# 2. Skopíruj environment variables (DÔLEŽITÉ!)
|
||||
# 2. Skopíruj premenné prostredia (DÔLEŽITÉ!)
|
||||
cp .env.example .env
|
||||
# Voliteľne: uprav .env pre vlastné nastavenia
|
||||
|
||||
# 3. Spusti Docker Compose (automaticky stiahne dependencies a spustí všetky služby)
|
||||
# 3. Spusti Docker Compose (automaticky stiahne závislosti a spustí všetky služby)
|
||||
docker-compose up -d
|
||||
|
||||
# 4. Otvor aplikáciu v prehliadači
|
||||
@ -38,7 +37,7 @@ docker-compose up -d
|
||||
# Backend API: http://localhost:3001/api
|
||||
```
|
||||
|
||||
Prvé spustenie trvá ~1-2 minúty (sťahovanie images + npm install).
|
||||
Prvé spustenie trvá ~1-2 minúty (sťahovanie obrazov + npm install).
|
||||
|
||||
---
|
||||
|
||||
@ -48,27 +47,27 @@ Prvé spustenie trvá ~1-2 minúty (sťahovanie images + npm install).
|
||||
|
||||
**⚠️ Pred spustením: Uisti sa, že máš `.env` súbor (pozri krok 2 v inštalácii vyššie)**
|
||||
|
||||
1. **Docker stiahne images:**
|
||||
1. **Docker stiahne obrazy:**
|
||||
- `postgres:alpine` (databáza)
|
||||
- `node:alpine` (Node.js runtime)
|
||||
- `node:alpine` (Node.js prostredie)
|
||||
|
||||
2. **Backend automaticky:**
|
||||
- Nainštaluje npm dependencies
|
||||
- Vygeneruje Prisma Client
|
||||
- Nainštaluje npm závislosti
|
||||
- Vygeneruje Prisma klienta
|
||||
- Vytvorí databázové tabuľky (`prisma db push`)
|
||||
- Spustí development server na porte **3001**
|
||||
- Spustí vývojársky server na porte **3001**
|
||||
|
||||
3. **Frontend automaticky:**
|
||||
- Nainštaluje npm dependencies
|
||||
- Spustí development server na porte **3000**
|
||||
- Nainštaluje npm závislosti
|
||||
- Spustí vývojársky server na porte **3000**
|
||||
|
||||
4. **PostgreSQL:**
|
||||
- Vytvorí databázu `sportbuddy`
|
||||
- Beží na porte **5432**
|
||||
|
||||
### Hot Reload (automatické načítanie zmien)
|
||||
### Automatické načítanie zmien (Hot Reload)
|
||||
|
||||
Vďaka **volume mounts** - okamžitý hot reload:
|
||||
Vďaka **pripojeniu zväzkov (volume mounts)** - okamžité načítanie zmien:
|
||||
|
||||
```
|
||||
Zmeníš súbor → Uložíš (Ctrl+S) → Zmena sa okamžite prejaví v prehliadači
|
||||
@ -78,18 +77,18 @@ Nemusíš reštartovať Docker kontajnery!
|
||||
|
||||
---
|
||||
|
||||
## Docker príkazy pre development
|
||||
## Príkazy Docker pre vývoj
|
||||
|
||||
```bash
|
||||
# Spustenie všetkých služieb
|
||||
docker-compose up -d
|
||||
|
||||
# Sledovanie logov (užitočné pre debugging)
|
||||
# Sledovanie záznamov (užitočné pre ladenie)
|
||||
docker-compose logs -f
|
||||
docker-compose logs -f backend # len backend
|
||||
docker-compose logs -f frontend # len frontend
|
||||
|
||||
# Rebuild po zmene Dockerfile
|
||||
# Opätovné zostavenie po zmene Dockerfile
|
||||
docker-compose up -d --build
|
||||
|
||||
# Zastavenie služieb
|
||||
@ -98,11 +97,11 @@ docker-compose down
|
||||
# Vyčistenie všetkého (vrátane databázy!)
|
||||
docker-compose down -v
|
||||
|
||||
# Exec do kontajnera (pre manuálne príkazy)
|
||||
# Pripojenie do kontajnera (pre manuálne príkazy)
|
||||
docker-compose exec backend sh
|
||||
docker-compose exec frontend sh
|
||||
|
||||
# Prisma Studio (GUI pre databázu)
|
||||
# Prisma Studio (grafické rozhranie pre databázu)
|
||||
docker-compose exec backend npx prisma studio
|
||||
# Otvor: http://localhost:5555
|
||||
```
|
||||
@ -114,41 +113,83 @@ docker-compose exec backend npx prisma studio
|
||||
```
|
||||
sportbuddy/
|
||||
├── apps/
|
||||
│ ├── backend/ # Backend API (Next.js + Prisma)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── app/api/ # API endpoints
|
||||
│ │ │ └── lib/ # Server utilities (auth, prisma)
|
||||
│ ├── backend/ # Backend API (Next.js + Prisma)
|
||||
│ │ ├── prisma/
|
||||
│ │ │ └── schema.prisma # Databázová schéma
|
||||
│ │ ├── Dockerfile # Unifikovaný Docker image (dev + prod)
|
||||
│ │ └── package.json
|
||||
│ │ │ ├── schema.prisma # Databázová schéma
|
||||
│ │ │ └── seed.ts # Počiatočné dáta do databázy (športoviská)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── app/api/ # API koncové body
|
||||
│ │ │ │ ├── activities/ # Activity CRUD + prihlásenie/odhlásenie
|
||||
│ │ │ │ ├── auth/ # Better Auth koncové body + vlastný reset hesla
|
||||
│ │ │ │ ├── profile/ # API používateľského profilu
|
||||
│ │ │ │ └── venues/ # API športovísk
|
||||
│ │ │ └── lib/ # Serverové utility
|
||||
│ │ │ ├── auth.ts # Better Auth konfigurácia
|
||||
│ │ │ ├── auth-client.ts # Nastavenie Auth klienta
|
||||
│ │ │ ├── email.ts # Brevo emailová služba (na reset hesla)
|
||||
│ │ │ ├── get-session.ts # Session helper
|
||||
│ │ │ └── prisma.ts # Prisma klient
|
||||
│ │ ├── Dockerfile # Multi-stage Docker (dev + prod)
|
||||
│ │ ├── middleware.ts # Next.js middleware
|
||||
│ │ ├── next.config.mjs # Next.js konfigurácia (CORS hlavičky)
|
||||
│ │ ├── package.json # Závislosti
|
||||
│ │ └── tsconfig.json # TypeScript konfigurácia
|
||||
│ │
|
||||
│ └── frontend/ # Frontend UI (Next.js + React)
|
||||
│ └── frontend/ # Frontend UI (Next.js + React)
|
||||
│ ├── public/
|
||||
│ │ ├── manifest.json # PWA manifest
|
||||
│ │ └── sw.js # Service Worker
|
||||
│ ├── src/
|
||||
│ │ ├── app/ # Next.js App Router stránky
|
||||
│ │ ├── components/ # React komponenty
|
||||
│ │ └── contexts/ # React Context (theme, atď.)
|
||||
│ ├── Dockerfile # Unifikovaný Docker image (dev + prod)
|
||||
│ └── package.json
|
||||
│ │ ├── app/ # Next.js App Router stránky
|
||||
│ │ │ ├── auth/ # Autentifikačné stránky (prihlásenie, registrácia)
|
||||
│ │ │ ├── dashboard/ # Stránka dashboardu
|
||||
│ │ │ ├── profile/ # Stránky profilu
|
||||
│ │ │ ├── globals.css # Globálne štýly
|
||||
│ │ │ ├── layout.tsx # Hlavné rozloženie
|
||||
│ │ │ └── page.tsx # Domovská stránka
|
||||
│ │ ├── components/ # React komponenty
|
||||
│ │ │ ├── ui/ # UI elementy (Button, Card, Input)
|
||||
│ │ │ ├── HtmlWrapper.tsx
|
||||
│ │ │ ├── Navigation.tsx
|
||||
│ │ │ ├── TemplateWrapper.tsx
|
||||
│ │ │ └── ThemeToggle.tsx
|
||||
│ │ ├── contexts/
|
||||
│ │ │ └── ThemeContext.tsx # Kontext tmavého režimu
|
||||
│ │ └── lib/
|
||||
│ │ └── auth-client.ts # Better Auth klient
|
||||
│ ├── Dockerfile # Viacstupňový Docker (dev + prod)
|
||||
│ ├── next.config.mjs # Next.js konfigurácia
|
||||
│ ├── package.json # Závislosti
|
||||
│ ├── postcss.config.mjs # PostCSS konfigurácia
|
||||
│ ├── tailwind.config.ts # Tailwind CSS konfigurácia
|
||||
│ └── tsconfig.json # TypeScript konfigurácia
|
||||
│
|
||||
├── packages/
|
||||
│ └── shared/ # Zdieľané TypeScript typy
|
||||
│ └── src/types/ # SportType, SkillLevel, atď.
|
||||
│ └── shared/ # Zdieľané TypeScript typy
|
||||
│ ├── src/
|
||||
│ │ ├── types/
|
||||
│ │ │ └── index.ts # Zdieľané typy (SportType, SkillLevel, atď.)
|
||||
│ │ └── index.ts # Exporty balíčka
|
||||
│ └── package.json
|
||||
│
|
||||
├── docker-compose.yml # Docker konfigurácia
|
||||
├── .env # Environment variables (lokálne, NIE v Gite!)
|
||||
├── .env.example # Template (commituj do Gitu)
|
||||
├── README.md # Návod na používanie
|
||||
└── USER_STORIES.md # Prehľad user stories a taskov
|
||||
├── .dockerignore # Dockerignore
|
||||
├── .env # Premenné prostredia (len lokálne, nie v Gite!)
|
||||
├── .env.example # Šablóna premenných prostredia (commituj toto)
|
||||
├── .gitignore # Gitignore
|
||||
├── docker-compose.yml # Docker Compose konfigurácia (3 služby)
|
||||
├── README.md # Projektová dokumentácia
|
||||
└── USER_STORIES.md # User stories & tasky
|
||||
```
|
||||
|
||||
**Poznámka:** V Docker projekte nepotrebujeme root `package.json`, `tsconfig.json` ani `package-lock.json` súbory. Každá aplikácia má vlastné závislosti.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
## Premenné prostredia
|
||||
|
||||
### Konfigurácia (.env súbor)
|
||||
|
||||
Projekt používa jeden `.env` súbor v roote. **Nikdy necommituj `.env` do Gitu!**
|
||||
Projekt používa jeden `.env` súbor v root adresári. **Nikdy necommituj `.env` do Gitu!**
|
||||
|
||||
**Pre nových vývojárov:**
|
||||
```bash
|
||||
@ -167,6 +208,8 @@ POSTGRES_DB=sportbuddy
|
||||
DATABASE_URL="postgresql://sportbuddy:sportbuddy123@postgres:5432/sportbuddy"
|
||||
BETTER_AUTH_SECRET="change-this-in-production"
|
||||
BETTER_AUTH_URL="http://localhost:3001"
|
||||
BREVO_API_KEY="tvoj-skutocny-brevo-api-key"
|
||||
pozn. - V apps/backend/src/lib/email.ts zmeň sender email na svoj overený email
|
||||
|
||||
# Frontend
|
||||
NEXT_PUBLIC_API_URL="http://localhost:3001"
|
||||
@ -178,11 +221,11 @@ GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
---
|
||||
|
||||
## Development workflow
|
||||
## Pracovný postup pri vývoji
|
||||
|
||||
### 1. Práca na user stories
|
||||
|
||||
Pozri [USER_STORIES.md](USER_STORIES.md) pre aktuálny stav projektu a zoznam taskov.
|
||||
Pozri [USER_STORIES.md](USER_STORIES.md) pre aktuálny stav projektu a zoznam úloh.
|
||||
|
||||
### 2. Práca s databázou (Prisma)
|
||||
|
||||
@ -191,27 +234,27 @@ Pozri [USER_STORIES.md](USER_STORIES.md) pre aktuálny stav projektu a zoznam ta
|
||||
# → Potom spusti:
|
||||
docker-compose exec backend npx prisma db push
|
||||
|
||||
# Otvor Prisma Studio (GUI)
|
||||
# Otvor Prisma Studio (grafické rozhranie)
|
||||
docker-compose exec backend npx prisma studio
|
||||
|
||||
# Reset databázy (POZOR: vymaže všetky dáta!)
|
||||
docker-compose exec backend npx prisma db push --force-reset
|
||||
```
|
||||
|
||||
### 3. Debugging
|
||||
### 3. Ladenie
|
||||
|
||||
```bash
|
||||
# Backend logy (API requesty, errory)
|
||||
# Záznamy backendu (API požiadavky, chyby)
|
||||
docker-compose logs -f backend
|
||||
|
||||
# Frontend logy (build output, errors)
|
||||
# Záznamy frontendu (výstup zostavenia, chyby)
|
||||
docker-compose logs -f frontend
|
||||
|
||||
# Databáza logy
|
||||
# Záznamy databázy
|
||||
docker-compose logs -f postgres
|
||||
```
|
||||
|
||||
### 4. Pridávanie nových dependencies
|
||||
### 4. Pridávanie nových závislostí
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
@ -229,20 +272,20 @@ docker-compose restart frontend
|
||||
|
||||
---
|
||||
|
||||
## Docker architektúra
|
||||
## Architektúra Docker projektu
|
||||
|
||||
### Unifikované Dockerfiles
|
||||
### Zjednotené Dockerfiles
|
||||
|
||||
Každý Dockerfile má **multi-stage build** s dvoma režimami:
|
||||
Každý Dockerfile má **viacstupňové zostavenie (multi-stage build)** s dvoma režimami:
|
||||
|
||||
- **Development stage** (používa docker-compose.yml)
|
||||
- Hot reload cez volume mounts
|
||||
- **Vývojársky režim** (používa docker-compose.yml)
|
||||
- Automatické načítanie zmien cez pripojenie zväzkov
|
||||
- `npm run dev`
|
||||
- Debug-friendly
|
||||
- Vhodné pre ladenie
|
||||
|
||||
- **Production stage** (pre nasadenie)
|
||||
- Optimalizovaný build
|
||||
- Multi-stage image (menší size)
|
||||
- **Produkčný režim** (pre nasadenie)
|
||||
- Optimalizované zostavenie
|
||||
- Viacstupňový obraz (menšia veľkosť)
|
||||
- `npm run build`
|
||||
|
||||
### Ako to funguje?
|
||||
@ -269,25 +312,25 @@ docker build --target production -t sportbuddy-frontend .
|
||||
|
||||
---
|
||||
|
||||
## Pre vývojárov - Best Practices
|
||||
## Pre vývojárov - Osvedčené postupy
|
||||
|
||||
### ✅ Commituj:
|
||||
- Všetok kód v `apps/*/src/`
|
||||
- `package.json`, `package-lock.json` (po pridaní dependencies)
|
||||
- `package.json`, `package-lock.json` (po pridaní závislostí)
|
||||
- `prisma/schema.prisma` (po zmene schémy)
|
||||
- `Dockerfile`, `docker-compose.yml`
|
||||
- `.env.example` (template bez secrets)
|
||||
|
||||
### ❌ Necommituj:
|
||||
- `node_modules/` (automaticky ignorované)
|
||||
- `.next/` (build artefakty)
|
||||
- `.env` (obsahuje secrets - NIKDY necommituj!)
|
||||
- `.vscode/`, `.idea/` (IDE nastavenia)
|
||||
- `.next/` (výsledky zostavenia obrazov)
|
||||
- `.env` (obsahuje tajné kľúče - NIKDY necommituj!)
|
||||
- `.vscode/`, `.idea/` (nastavenia IDE)
|
||||
|
||||
### 🔄 Po každom git pull:
|
||||
|
||||
```bash
|
||||
# Ak niekto zmenil Dockerfile alebo dependencies
|
||||
# Ak niekto zmenil Dockerfile alebo závislosti
|
||||
docker-compose down
|
||||
docker-compose up -d --build
|
||||
```
|
||||
@ -295,10 +338,10 @@ docker-compose up -d --build
|
||||
### 🐛 Keď niečo nefunguje:
|
||||
|
||||
```bash
|
||||
# 1. Skús restart
|
||||
# 1. Skús reštart
|
||||
docker-compose restart
|
||||
|
||||
# 2. Skús rebuild
|
||||
# 2. Skús opätovné zostavenie
|
||||
docker-compose up -d --build
|
||||
|
||||
# 3. Vyčisti všetko a začni odznova
|
||||
@ -306,7 +349,7 @@ docker-compose down -v
|
||||
cp .env.example .env # Obnov .env ak bol zmazaný
|
||||
docker-compose up -d --build
|
||||
|
||||
# 4. Skontroluj logy
|
||||
# 4. Skontroluj záznamy
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
@ -314,8 +357,8 @@ docker-compose logs -f
|
||||
|
||||
## Ďalšie kroky
|
||||
|
||||
1. **Prečítaj si** [USER_STORIES.md](USER_STORIES.md) - zoznam všetkých user stories a ich stav
|
||||
2. **Vyber si task**
|
||||
1. **Prečítaj si** [USER_STORIES.md](USER_STORIES.md) - zoznam všetkých používateľských príbehov a ich stav
|
||||
2. **Vyber si úlohu**
|
||||
3. **Pozri databázovú schému** - `apps/backend/prisma/schema.prisma`
|
||||
4. **Pozri API - backend** - `apps/backend/src/app/api/`
|
||||
|
||||
|
||||
127
USER_STORIES.md
127
USER_STORIES.md
@ -20,7 +20,7 @@ aby som mohol používať aplikáciu
|
||||
- ✅ Registračný formulár (/register)
|
||||
- ✅ Prihlasovací formulár (/login)
|
||||
- ✅ Validácia (email formát, heslo min 8 znakov)
|
||||
- ✅ Hash hesla (bcrypt)
|
||||
- ✅ Hash hesla (scrypt via Better Auth)
|
||||
- ✅ Session management (localStorage)
|
||||
- ✅ Responzívny dizajn formulárov
|
||||
|
||||
@ -78,7 +78,7 @@ aby som mohol prezentovať svoje športové záujmy a mať prehľad o mojich uda
|
||||
|
||||
## US-003: Vytvorenie aktivity
|
||||
|
||||
**Status:** 🔄 WIP (Work In Progress)
|
||||
**Status:** ✅ HOTOVÉ
|
||||
|
||||
Ako používateľ
|
||||
chcem vytvoriť novú športovú aktivitu
|
||||
@ -91,26 +91,26 @@ aby som našiel spoluhráčov
|
||||
- ✅ API: POST /api/activities
|
||||
- ✅ Automatické pridanie tvorcu ako účastníka
|
||||
- ✅ Validácia (dátum v budúcnosti, cena >= 0)
|
||||
- ⏸️ Formulár na vytvorenie (/activities/create)
|
||||
- ⏸️ Polia: názov, šport (dropdown), dátum, čas, miesto, max hráčov, úroveň, cena, popis
|
||||
- ⏸️ React Hook Form + Zod validácia
|
||||
- ⏸️ Loading state pri submit
|
||||
- ⏸️ Redirect na detail po vytvorení
|
||||
- ⏸️ Responzívny formulár
|
||||
- ✅ Formulár na vytvorenie (/activities/create)
|
||||
- ✅ Polia: názov, šport (dropdown), dátum, čas, miesto, max hráčov, úroveň, cena, popis
|
||||
- ✅ React Hook Form + Zod validácia
|
||||
- ✅ Loading state pri submit
|
||||
- ✅ Redirect na detail po vytvorení
|
||||
- ✅ Responzívny formulár
|
||||
|
||||
### Výsledné funkcie:
|
||||
- ✅ API endpoint funguje
|
||||
- ✅ Funkčná validácia na BE
|
||||
- ✅ Aktivita sa uloží do DB
|
||||
- ⏸️ Frontend formulár chýba
|
||||
|
||||
**Poznámka:** API je hotové, ale chýba frontend formulár na vytvorenie aktivity.
|
||||
- ✅ Frontend formulár implementovaný a funkčný
|
||||
- ✅ Automatické načítanie venues
|
||||
- ✅ Validácia na FE a BE
|
||||
|
||||
---
|
||||
|
||||
## US-004: Zoznam a detail aktivít
|
||||
|
||||
**Status:** 🔄 WIP (Work In Progress)
|
||||
**Status:** ✅ HOTOVÉ
|
||||
|
||||
Ako používateľ
|
||||
chcem vidieť zoznam aktivít a ich detail
|
||||
@ -122,30 +122,30 @@ aby som vedel, čo je k dispozícii
|
||||
- ✅ API: GET /api/activities (pagination 20/page)
|
||||
- ✅ API: GET /api/activities/[id]
|
||||
- ✅ Filtrovanie podľa športu, mesta, statusu
|
||||
- ⏸️ Stránka zoznamu (/activities)
|
||||
- ⏸️ Card komponenta pre aktivitu
|
||||
- ⏸️ Zobrazenie: názov, šport, dátum, čas, miesto, voľné miesta
|
||||
- ⏸️ Loading skeleton
|
||||
- ⏸️ Empty state ("Žiadne aktivity")
|
||||
- ⏸️ Detail stránka (/activities/[id])
|
||||
- ⏸️ Kompletné info + mapa (Google Maps embed)
|
||||
- ⏸️ Zoznam účastníkov
|
||||
- ⏸️ Progress bar obsadenosti
|
||||
- ⏸️ Responzívny grid/detail
|
||||
- ✅ Stránka zoznamu (/activities)
|
||||
- ✅ Card komponenta pre aktivitu
|
||||
- ✅ Zobrazenie: názov, šport, dátum, čas, miesto, voľné miesta
|
||||
- ✅ Loading skeleton
|
||||
- ✅ Empty state ("Žiadne aktivity")
|
||||
- ✅ Detail stránka (/activities/[id])
|
||||
- ✅ Kompletné info + mapa (Google Maps embed)
|
||||
- ✅ Zoznam účastníkov
|
||||
- ✅ Progress bar obsadenosti
|
||||
- ✅ Responzívny grid/detail
|
||||
|
||||
### Výsledné funkcie:
|
||||
- ✅ API endpoints fungujú
|
||||
- ⏸️ Zoznam aktivít (UI)
|
||||
- ⏸️ Detail aktivity (UI)
|
||||
- ⏸️ Pagination funguje
|
||||
|
||||
**Poznámka:** Backend API je kompletný s filtrovaním, ale chýba celý frontend UI.
|
||||
- ✅ Zoznam aktivít (UI) s kartami
|
||||
- ✅ Detail aktivity s kompletnou informáciou
|
||||
- ✅ Mapa športoviska
|
||||
- ✅ Progress bar a zoznam účastníkov
|
||||
- ✅ Loading states a empty states
|
||||
|
||||
---
|
||||
|
||||
## US-005: Prihlasovanie na aktivity
|
||||
|
||||
**Status:** 🔄 WIP (Work In Progress)
|
||||
**Status:** ✅ HOTOVÉ
|
||||
|
||||
Ako používateľ
|
||||
chcem sa prihlásiť na aktivitu
|
||||
@ -159,20 +159,20 @@ aby som rezervoval miesto
|
||||
- ✅ Kontrola voľnej kapacity
|
||||
- ✅ Kontrola duplicity (už prihlásený)
|
||||
- ✅ Aktualizácia počtu hráčov
|
||||
- ⏸️ Tlačidlo "Prihlásiť sa" na detaile
|
||||
- ⏸️ Tlačidlo "Odhlásiť sa" ak som prihlásený
|
||||
- ⏸️ API: DELETE /api/activities/[id]/leave
|
||||
- ⏸️ Optimistic updates (TanStack Query)
|
||||
- ⏸️ Toast notifikácie
|
||||
- ⏸️ Badge "Prihlásený" na karte aktivity
|
||||
- ✅ Tlačidlo "Prihlásiť sa" na detaile
|
||||
- ✅ Tlačidlo "Odhlásiť sa" ak som prihlásený
|
||||
- ✅ API: DELETE /api/activities/[id]/join
|
||||
- ✅ Optimistic updates (TanStack Query)
|
||||
- ✅ Toast notifikácie
|
||||
- ✅ Badge "Prihlásený" na karte aktivity
|
||||
|
||||
### Výsledné funkcie:
|
||||
- ✅ Prihlásenie funguje (API)
|
||||
- ⏸️ Odhlásenie funguje
|
||||
- ✅ Prihlásenie funguje (API + UI)
|
||||
- ✅ Odhlásenie funguje (API + UI)
|
||||
- ✅ Počet hráčov sa aktualizuje
|
||||
- ⏸️ UI komponenty chýbajú
|
||||
|
||||
**Poznámka:** Join API je hotové, ale chýba leave endpoint a všetky UI elementy.
|
||||
- ✅ UI komponenty implementované
|
||||
- ✅ Automatický refresh dát po akcii
|
||||
- ✅ Vizuálna indikácia stavu (organizátor/účastník)
|
||||
|
||||
---
|
||||
|
||||
@ -805,6 +805,55 @@ aby som hral s ľuďmi na mojej úrovni
|
||||
- ⏸️ User suggestions
|
||||
- ⏸️ Weekly digest
|
||||
|
||||
---
|
||||
|
||||
## US-019: Reset hesla cez email
|
||||
|
||||
**Status:** ✅ HOTOVÉ
|
||||
|
||||
Ako používateľ
|
||||
chcem si obnoviť heslo ak som ho zabudol
|
||||
aby som sa mohol znova prihlásiť do aplikácie
|
||||
|
||||
**Vývojár:** Kamil Berecký
|
||||
|
||||
### Tasky:
|
||||
- ✅ Prisma schema: PasswordReset model (token, userId, expiresAt, used)
|
||||
- ✅ Email service setup (Brevo - 300 emailov/deň zadarmo)
|
||||
- ✅ Verified sender email (kberecky@gmail.com) v Brevo dashboard
|
||||
- ✅ API: POST /api/auth/forgot-password (generuje token a posiela email)
|
||||
- ✅ API: POST /api/auth/reset-password (resetuje heslo s tokenom)
|
||||
- ✅ Email template pre reset link (HTML s responsive dizajnom)
|
||||
- ✅ Stránka /auth/forgot-password (formulár na zadanie emailu)
|
||||
- ✅ Stránka /auth/reset-password?token=xxx (formulár na nové heslo)
|
||||
- ✅ Validácia tokenu (expiracia 1 hodina, used flag)
|
||||
- ✅ Bezpečné generovanie tokenu (crypto.randomBytes + SHA-256)
|
||||
- ✅ Hash nového hesla (scrypt s Better Auth parametrami: N:16384, r:16, p:1, dkLen:64)
|
||||
- ✅ Vymazanie tokenu po použití (used = true)
|
||||
- ✅ Správne URL pre frontend (NEXT_PUBLIC_FRONTEND_URL)
|
||||
- ✅ "Zabudli ste heslo?" link na signin stránke
|
||||
- ✅ Success/Error feedback messages (slovenský jazyk)
|
||||
- ✅ Rate limiting (max 3 requesty za hodinu na email)
|
||||
- ✅ Responzívny dizajn
|
||||
- ✅ Development mode (console log namiesto emailu keď BREVO_API_KEY="brevo_test_key")
|
||||
- ✅ Production mode (Brevo API integration s messageId tracking)
|
||||
- ✅ Všetky sessions sa vymažú po resete (force re-login)
|
||||
|
||||
### Výsledné funkcie:
|
||||
- ✅ Odoslanie reset emailu funguje (Brevo API)
|
||||
- ✅ Token validácia funguje (expiracia, duplicita, použitie)
|
||||
- ✅ Reset hesla funguje s kompatibilným scrypt hashom
|
||||
- ✅ Email template je pekný a funkčný s correct frontend URL
|
||||
- ✅ Development mode bez Brevo API kľúča (console log)
|
||||
- ✅ Production ready s Brevo integration
|
||||
- ✅ Všetky sessions sa vymažú po resete (security)
|
||||
- ✅ Prihlásenie funguje po resete hesla
|
||||
|
||||
**Technické detaily:**
|
||||
- Email service: Brevo (@getbrevo/brevo SDK)
|
||||
- Password hashing: Node.js crypto scrypt (Better Auth compatible)
|
||||
- Token storage: PostgreSQL (PasswordReset table)
|
||||
- Email delivery: 300/day limit (Brevo free tier)
|
||||
|
||||
---
|
||||
|
||||
|
||||
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",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:push": "prisma db push",
|
||||
"prisma:migrate": "prisma migrate dev"
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getbrevo/brevo": "latest",
|
||||
"@prisma/client": "latest",
|
||||
"bcryptjs": "latest",
|
||||
"better-auth": "latest",
|
||||
"next": "latest",
|
||||
"react": "latest",
|
||||
@ -29,6 +33,7 @@
|
||||
"eslint": "latest",
|
||||
"eslint-config-next": "latest",
|
||||
"prisma": "latest",
|
||||
"tsx": "latest",
|
||||
"typescript": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ model User {
|
||||
participations Participation[]
|
||||
reviews Review[]
|
||||
favoriteVenues VenueFavorite[]
|
||||
passwordResets PasswordReset[]
|
||||
}
|
||||
|
||||
// Profile model for user details (separate from auth)
|
||||
@ -89,6 +90,20 @@ model Verification {
|
||||
@@unique([identifier, value])
|
||||
}
|
||||
|
||||
model PasswordReset {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
used Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([token])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Sport types enumeration
|
||||
enum SportType {
|
||||
FOOTBALL
|
||||
|
||||
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: {
|
||||
userId: session.user.id,
|
||||
activityId: id,
|
||||
status: "confirmed",
|
||||
status: "CONFIRMED",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -21,7 +21,6 @@ export async function GET(
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
skillLevel: true,
|
||||
},
|
||||
},
|
||||
participations: {
|
||||
@ -31,7 +30,6 @@ export async function GET(
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
skillLevel: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -124,7 +124,7 @@ export async function POST(request: NextRequest) {
|
||||
data: {
|
||||
userId: session.user.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",
|
||||
"types": "./src/index.ts",
|
||||
"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