feat: Google Maps integration and US-012 filter fields
- Added Google Maps LocationPicker with autocomplete - Added location, locationName, latitude, longitude to Activity model - Extended Activity model with gender, minAge, maxAge, price fields - Updated activity creation form with custom styled inputs - Added map view on activity detail page - Added 'Open in Maps' button with deep linking - Custom styled autocomplete dropdown - Updated USER_STORIES.md with completed tasks
This commit is contained in:
parent
29829adb1d
commit
877100633b
@ -22,6 +22,11 @@ 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"
|
||||||
|
|
||||||
|
# Google Maps API Key (for location picker)
|
||||||
|
# Get API key from: https://console.cloud.google.com/google/maps-apis
|
||||||
|
# Enable: Places API, Maps JavaScript API, Geocoding API
|
||||||
|
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=""
|
||||||
|
|
||||||
# Brevo Email Service (for password reset emails)
|
# Brevo Email Service (for password reset emails)
|
||||||
# Get API key from: https://app.brevo.com/settings/keys/api
|
# Get API key from: https://app.brevo.com/settings/keys/api
|
||||||
# Leave empty for development mode (emails logged to console)
|
# Leave empty for development mode (emails logged to console)
|
||||||
|
|||||||
120
GOOGLE_MAPS_SETUP.md
Normal file
120
GOOGLE_MAPS_SETUP.md
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# Google Maps API - Nastavenie
|
||||||
|
|
||||||
|
Aplikácia používa Google Maps API pre výber lokácie aktivít. Tento návod vám ukáže, ako získať API kľúč.
|
||||||
|
|
||||||
|
## Postup získania API kľúča
|
||||||
|
|
||||||
|
### 1. Vytvorenie projektu v Google Cloud Console
|
||||||
|
|
||||||
|
1. Prejdite na [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
|
2. Prihláste sa pomocou Google účtu
|
||||||
|
3. Kliknite na dropdown v hornej časti stránky a vyberte "New Project"
|
||||||
|
4. Zadajte názov projektu (napr. "SportBuddy") a kliknite "Create"
|
||||||
|
|
||||||
|
### 2. Aktivácia potrebných API
|
||||||
|
|
||||||
|
1. V Google Cloud Console prejdite na **APIs & Services > Library**
|
||||||
|
2. Vyhľadajte a aktivujte nasledujúce API:
|
||||||
|
- **Maps JavaScript API** - pre zobrazenie máp
|
||||||
|
- **Maps Embed API** - pre vložené mapy (iframe)
|
||||||
|
- **Places API** - pre autocomplete adries (legacy)
|
||||||
|
- **Places API (New)** - pre zobrazenie miesta na mape
|
||||||
|
- **Geocoding API** - pre získanie GPS súradníc
|
||||||
|
|
||||||
|
Pre každé API:
|
||||||
|
- Kliknite na názov API
|
||||||
|
- Kliknite na tlačidlo "Enable"
|
||||||
|
|
||||||
|
### 3. Vytvorenie API kľúča
|
||||||
|
|
||||||
|
1. Prejdite na **APIs & Services > Credentials**
|
||||||
|
2. Kliknite na **+ CREATE CREDENTIALS** v hornej časti
|
||||||
|
3. Vyberte **API key**
|
||||||
|
4. API kľúč bude vytvorený - **skopírujte ho**
|
||||||
|
|
||||||
|
### 4. Obmedzenie API kľúča (Dôležité pre bezpečnosť!)
|
||||||
|
|
||||||
|
Po vytvorení kľúča je dôležité ho obmedziť:
|
||||||
|
|
||||||
|
1. V zozname API kľúčov kliknite na ikonu tužky (Edit) vedľa vášho kľúča
|
||||||
|
2. V sekcii **Application restrictions**:
|
||||||
|
- **PRE DEVELOPMENT:** Vyberte "None" (žiadne obmedzenia)
|
||||||
|
- **PRE PRODUCTION:** Vyberte "HTTP referrers" a pridajte vašu doménu
|
||||||
|
- Poznámka: Maps Embed API má problémy s HTTP referrers v development móde
|
||||||
|
3. V sekcii **API restrictions**:
|
||||||
|
- Vyberte "Restrict key"
|
||||||
|
- Zaškrtnite:
|
||||||
|
- Maps JavaScript API
|
||||||
|
- Maps Embed API
|
||||||
|
- Places API
|
||||||
|
- Places API (New)
|
||||||
|
- Geocoding API
|
||||||
|
4. Kliknite na **Save**
|
||||||
|
|
||||||
|
**DÔLEŽITÉ:**
|
||||||
|
- Po uložení zmien môže trvať až 5 minút, kým sa zmeny prejavia
|
||||||
|
- Pre development odporúčame nastaviť "Application restrictions" na "None"
|
||||||
|
- Pre production použite HTTP referrers s vašou doménou
|
||||||
|
|
||||||
|
### 5. Pridanie API kľúča do projektu
|
||||||
|
|
||||||
|
1. V koreňovom priečinku projektu vytvorte súbor `.env` (ak neexistuje)
|
||||||
|
2. Skopírujte obsah zo súboru `.env.example`
|
||||||
|
3. Do premennej `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` vložte váš API kľúč:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="váš-api-kľúč-tu"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Reštart aplikácie
|
||||||
|
|
||||||
|
Po pridaní API kľúča reštartujte Docker kontajnery:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose restart frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ceny a limity
|
||||||
|
|
||||||
|
Google Maps API ponúka **$200 mesačného kreditu zadarmo**, což pokrýva:
|
||||||
|
- Cca 28,000 načítaní máp mesačne
|
||||||
|
- Cca 100,000 autocomplete requestov mesačne
|
||||||
|
|
||||||
|
Pre väčšinu aplikácií v development a malých produkčných použití je to dostačujúce.
|
||||||
|
|
||||||
|
## Riešenie problémov
|
||||||
|
|
||||||
|
### "Google Maps Platform rejected your request" alebo HTTP 403
|
||||||
|
**Najčastejší problém s Maps Embed API v development móde!**
|
||||||
|
|
||||||
|
**Rýchle riešenie:**
|
||||||
|
1. Prejdite na **Google Cloud Console > APIs & Services > Credentials**
|
||||||
|
2. Kliknite na Edit vedľa vášho API kľúča
|
||||||
|
3. V sekcii **Application restrictions** zvoľte **"None"**
|
||||||
|
4. Kliknite **Save**
|
||||||
|
5. Počkajte 2-5 minút
|
||||||
|
6. Vyčistite cache prehliadača (Ctrl+Shift+Delete)
|
||||||
|
7. Obnovte stránku (F5)
|
||||||
|
|
||||||
|
**Poznámka:** Pre production nasadenie použite HTTP referrers s vašou doménou (napr. `https://vasadomena.sk/*`)
|
||||||
|
|
||||||
|
### "Google Maps API kľúč nie je nastavený"
|
||||||
|
- Skontrolujte, že ste vytvorili `.env` súbor
|
||||||
|
- Overte, že premenná je správne pomenovaná: `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY`
|
||||||
|
- Reštartujte Docker kontajner
|
||||||
|
|
||||||
|
### "This page can't load Google Maps correctly"
|
||||||
|
- Skontrolujte, že ste aktivovali všetky potrebné API (Maps JavaScript, Maps Embed, Places, Places (New), Geocoding)
|
||||||
|
- Skúste odstrániť Application restrictions (nastaviť na "None")
|
||||||
|
|
||||||
|
### Autocomplete nefunguje
|
||||||
|
- Uistite sa, že je aktivované **Places API**
|
||||||
|
- Skontrolujte obmedzenia API kľúča
|
||||||
|
|
||||||
|
## Alternatívne riešenia (bez Google Maps)
|
||||||
|
|
||||||
|
Ak nechcete používať Google Maps API, aplikácia funguje aj s manuálnym zadávaním adresy:
|
||||||
|
- Používateľ môže zadať adresu textom
|
||||||
|
- GPS súradnice nebudú dostupné, ale aplikácia bude fungovať
|
||||||
|
|
||||||
|
Pre vypnutie Google Maps integrácie jednoducho nenastávajte `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` premennú.
|
||||||
88
LOCATION_CHANGES.md
Normal file
88
LOCATION_CHANGES.md
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# Zmeny v systéme lokácií aktivít
|
||||||
|
|
||||||
|
## Prehľad zmien
|
||||||
|
|
||||||
|
Namiesto výberu športoviska zo zoznamu teraz používatelia zadávajú adresu pomocou Google Maps.
|
||||||
|
|
||||||
|
## Implementované zmeny
|
||||||
|
|
||||||
|
### 1. Databázová schéma (Prisma)
|
||||||
|
- Pridané polia do `Activity` modelu:
|
||||||
|
- `location` (String) - plná adresa z Google Maps
|
||||||
|
- `locationName` (String?) - voliteľný vlastný názov miesta
|
||||||
|
- `latitude` (Float?) - GPS súradnica
|
||||||
|
- `longitude` (Float?) - GPS súradnica
|
||||||
|
- `venueId` je teraz voliteľné (pre spätnú kompatibilitu)
|
||||||
|
|
||||||
|
### 2. Backend API
|
||||||
|
- Aktualizovaná validácia v `/api/activities`:
|
||||||
|
- `location` je povinné pole
|
||||||
|
- `locationName`, `latitude`, `longitude` sú voliteľné
|
||||||
|
- `venueId` je teraz voliteľné
|
||||||
|
|
||||||
|
### 3. Frontend komponenty
|
||||||
|
|
||||||
|
#### LocationPicker komponent (`components/LocationPicker.tsx`)
|
||||||
|
- Google Maps Places Autocomplete pre vyhľadávanie adries
|
||||||
|
- Automatické získanie GPS súradníc
|
||||||
|
- Podpora pre Slovensko (country: "sk")
|
||||||
|
- Fallback na manuálny vstup ak Google Maps API nie je dostupné
|
||||||
|
|
||||||
|
#### Formulár na vytvorenie aktivity (`activities/create/page.tsx`)
|
||||||
|
- Odstránený výber športoviska
|
||||||
|
- Pridaný LocationPicker komponent
|
||||||
|
- Validácia adresy pred odoslaním
|
||||||
|
|
||||||
|
#### Detail aktivity (`activities/[id]/page.tsx`)
|
||||||
|
- Zobrazenie adresy namiesto športoviska
|
||||||
|
- Zobrazenie vlastného názvu miesta (ak je zadaný)
|
||||||
|
- Mapa s GPS súradnicami (ak sú dostupné)
|
||||||
|
|
||||||
|
#### Zoznam aktivít (`activities/page.tsx`)
|
||||||
|
- Zobrazenie adresy alebo názvu miesta v karte aktivity
|
||||||
|
|
||||||
|
## Nastavenie Google Maps API
|
||||||
|
|
||||||
|
Pre plnú funkcionalitu je potrebný Google Maps API kľúč:
|
||||||
|
|
||||||
|
1. Postupujte podľa návodu v súbore `GOOGLE_MAPS_SETUP.md`
|
||||||
|
2. Pridajte kľúč do `.env`:
|
||||||
|
```
|
||||||
|
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="váš-api-kľúč"
|
||||||
|
```
|
||||||
|
3. Reštartujte aplikáciu
|
||||||
|
|
||||||
|
### Bez Google Maps API
|
||||||
|
Aplikácia funguje aj bez API kľúča:
|
||||||
|
- Používateľ zadá adresu manuálne do textového poľa
|
||||||
|
- GPS súradnice nebudú dostupné
|
||||||
|
- Mapa sa nezobrazí v detaile aktivity
|
||||||
|
|
||||||
|
## Migrácia existujúcich dát
|
||||||
|
|
||||||
|
Existujúce aktivity v databáze majú stále nastavené `venueId`, ktoré zostáva zachované pre spätnú kompatibilitu. Pri zobrazení aktivít sa preferuje `location` pred `venue`.
|
||||||
|
|
||||||
|
Pre migráciu starých dát by bolo potrebné:
|
||||||
|
1. Načítať všetky aktivity s `venueId`
|
||||||
|
2. Skopírovať adresu z `venue` do `location`
|
||||||
|
3. Voliteľne použiť Geocoding API na získanie GPS súradníc
|
||||||
|
|
||||||
|
## Technické detaily
|
||||||
|
|
||||||
|
### TypeScript typy
|
||||||
|
- `google-maps.d.ts` - definície pre Google Maps API
|
||||||
|
- Aktualizované interface pre `Activity` vo všetkých komponentoch
|
||||||
|
|
||||||
|
### Závislosti
|
||||||
|
- Žiadne nové NPM balíčky
|
||||||
|
- Google Maps sa načítava priamo cez script tag
|
||||||
|
- Používa sa natívny Places Autocomplete API
|
||||||
|
|
||||||
|
## Testovanie
|
||||||
|
|
||||||
|
1. Vytvorte novú aktivitu
|
||||||
|
2. Začnite písať adresu (napr. "Bratislava")
|
||||||
|
3. Vyberte adresu zo zoznamu návrhov
|
||||||
|
4. Overte že sa zobrazuje náhľad vybranej lokácie
|
||||||
|
5. Odošlite formulár
|
||||||
|
6. V detaile aktivity skontrolujte zobrazenie adresy a mapy
|
||||||
@ -214,6 +214,10 @@ pozn. - V apps/backend/src/lib/email.ts zmeň sender email na svoj overený emai
|
|||||||
# Frontend
|
# Frontend
|
||||||
NEXT_PUBLIC_API_URL="http://localhost:3001"
|
NEXT_PUBLIC_API_URL="http://localhost:3001"
|
||||||
|
|
||||||
|
# Google Maps API (pre výber lokácie aktivít)
|
||||||
|
# Pozri GOOGLE_MAPS_SETUP.md pre inštrukcie
|
||||||
|
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=""
|
||||||
|
|
||||||
# OAuth (voliteľné)
|
# OAuth (voliteľné)
|
||||||
GOOGLE_CLIENT_ID=""
|
GOOGLE_CLIENT_ID=""
|
||||||
GOOGLE_CLIENT_SECRET=""
|
GOOGLE_CLIENT_SECRET=""
|
||||||
|
|||||||
@ -23,12 +23,14 @@ aby som mohol používať aplikáciu
|
|||||||
- ✅ Hash hesla (scrypt via Better Auth)
|
- ✅ Hash hesla (scrypt via Better Auth)
|
||||||
- ✅ Session management (localStorage)
|
- ✅ Session management (localStorage)
|
||||||
- ✅ Responzívny dizajn formulárov
|
- ✅ Responzívny dizajn formulárov
|
||||||
|
- ✅ Podmienené zobrazenie "Vytvoriť účet" button (skrytie pre prihlásených) - Jozef Kovalčín
|
||||||
|
|
||||||
### Výsledné funkcie:
|
### Výsledné funkcie:
|
||||||
- ✅ Fungujúca registrácia
|
- ✅ Fungujúca registrácia
|
||||||
- ✅ Fungujúce prihlásenie
|
- ✅ Fungujúce prihlásenie
|
||||||
- ✅ Session persistence
|
- ✅ Session persistence
|
||||||
- ✅ Redirect na /dashboard po prihlásení
|
- ✅ Redirect na /dashboard po prihlásení
|
||||||
|
- ✅ Homepage nezobrazuje CTA button pre prihlásených používateľov
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -97,6 +99,10 @@ aby som našiel spoluhráčov
|
|||||||
- ✅ 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
|
||||||
|
- ✅ Google Maps LocationPicker pre výber adresy - Jozef Kovalčín
|
||||||
|
- ✅ Automatické uloženie GPS súradníc a názvu miesta - Jozef Kovalčín
|
||||||
|
- ✅ Rozšírenie formulára o filter polia (pohlavie, min vek, max vek, cena) - Jozef Kovalčín
|
||||||
|
- ✅ Custom input tlačidlá s +/- tlačidlami - Jozef Kovalčín
|
||||||
|
|
||||||
### Výsledné funkcie:
|
### Výsledné funkcie:
|
||||||
- ✅ API endpoint funguje
|
- ✅ API endpoint funguje
|
||||||
@ -105,6 +111,9 @@ aby som našiel spoluhráčov
|
|||||||
- ✅ Frontend formulár implementovaný a funkčný
|
- ✅ Frontend formulár implementovaný a funkčný
|
||||||
- ✅ Automatické načítanie venues
|
- ✅ Automatické načítanie venues
|
||||||
- ✅ Validácia na FE a BE
|
- ✅ Validácia na FE a BE
|
||||||
|
- ✅ Google Maps autocomplete pre adresu
|
||||||
|
- ✅ Uloženie lokácie, GPS súradníc a názvu miesta
|
||||||
|
- ✅ Všetky US-012 filter polia v create forme
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -132,14 +141,19 @@ aby som vedel, čo je k dispozícii
|
|||||||
- ✅ Zoznam účastníkov
|
- ✅ Zoznam účastníkov
|
||||||
- ✅ Progress bar obsadenosti
|
- ✅ Progress bar obsadenosti
|
||||||
- ✅ Responzívny grid/detail
|
- ✅ Responzívny grid/detail
|
||||||
|
- ✅ Zobrazenie všetkých US-012 filter polí - Jozef Kovalčín
|
||||||
|
- ✅ Google Maps iframe s názvom miesta - Jozef Kovalčín
|
||||||
|
- ✅ "Otvoriť v Mapách" tlačidlo s deep linking - Jozef Kovalčín
|
||||||
|
|
||||||
### Výsledné funkcie:
|
### Výsledné funkcie:
|
||||||
- ✅ API endpoints fungujú
|
- ✅ API endpoints fungujú
|
||||||
- ✅ Zoznam aktivít (UI) s kartami
|
- ✅ Zoznam aktivít (UI) s kartami
|
||||||
- ✅ Detail aktivity s kompletnou informáciou
|
- ✅ Detail aktivity s kompletnou informáciou
|
||||||
- ✅ Mapa športoviska
|
- ✅ Mapa športoviska s názvom lokality
|
||||||
- ✅ Progress bar a zoznam účastníkov
|
- ✅ Progress bar a zoznam účastníkov
|
||||||
- ✅ Loading states a empty states
|
- ✅ Loading states a empty states
|
||||||
|
- ✅ Zobrazenie pohlavia, vekového rozpätia a ceny
|
||||||
|
- ✅ Deep linking do Google Maps/Apple Maps
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -269,33 +283,40 @@ aby som nemusel vytvárať nové heslo a prihlásenie bolo rýchlejšie
|
|||||||
|
|
||||||
## US-009: Mapa s lokalitami aktivít
|
## US-009: Mapa s lokalitami aktivít
|
||||||
|
|
||||||
**Status:** 📋 PLANNED
|
**Status:** ✅ HOTOVÉ
|
||||||
|
|
||||||
Ako používateľ
|
Ako používateľ
|
||||||
chcem vidieť polohu aktivít na Google Maps
|
chcem vidieť polohu aktivít na Google Maps
|
||||||
aby som vedel, kde sa aktivita koná a ako ďaleko to mám
|
aby som vedel, kde sa aktivita koná a ako ďaleko to mám
|
||||||
|
|
||||||
**Vývojár:** -
|
**Vývojár:** Jozef Kovalčín
|
||||||
|
|
||||||
### Tasky:
|
### Tasky:
|
||||||
- ⏸️ Google Maps API setup (API key)
|
- ✅ Google Maps API setup (API key)
|
||||||
- ⏸️ Prisma schema: pridať lat/lng do Activity modelu
|
- ✅ Prisma schema: pridať lat/lng do Activity modelu
|
||||||
- ⏸️ Geocoding pri vytváraní aktivity (mesto/adresa → súradnice)
|
- ✅ Prisma schema: location, locationName
|
||||||
- ⏸️ API: GET /api/activities s lat/lng dátami
|
- ✅ LocationPicker komponenta s Google Maps Autocomplete
|
||||||
- ⏸️ React komponenta: MapView s Google Maps embed/SDK
|
- ✅ Geocoding pri vytváraní aktivity (adresa → súradnice)
|
||||||
|
- ✅ API: GET /api/activities s lat/lng dátami
|
||||||
|
- ✅ React komponenta: LocationPicker s Google Maps autocomplete SDK
|
||||||
|
- ✅ Mapa na detail stránke (/activities/[id]) s iframe embed
|
||||||
|
- ✅ Mapa zobrazuje názov miesta namiesto GPS súradníc
|
||||||
|
- ✅ Tlačidlo "Otvoriť v Mapách" s deep linking
|
||||||
|
- ✅ Responzívna mapa (mobile/desktop)
|
||||||
|
- ✅ Custom styling pre autocomplete dropdown
|
||||||
- ⏸️ Markery pre jednotlivé aktivity na mape
|
- ⏸️ Markery pre jednotlivé aktivity na mape
|
||||||
- ⏸️ Info window pri kliknutí na marker (názov, šport, čas)
|
- ⏸️ Info window pri kliknutí na marker (názov, šport, čas)
|
||||||
- ⏸️ Prepínanie medzi zoznam view a mapa view na /activities
|
- ⏸️ Prepínanie medzi zoznam view a mapa view na /activities
|
||||||
- ⏸️ Mapa na detail stránke (/activities/[id])
|
|
||||||
- ⏸️ Directions link (navigácia do Google Maps)
|
|
||||||
- ⏸️ Responzívna mapa (mobile/desktop)
|
|
||||||
- ⏸️ Loading state pre mapu
|
|
||||||
|
|
||||||
### Výsledné funkcie:
|
### Výsledné funkcie:
|
||||||
|
- ✅ Google Maps autocomplete pri vytváraní aktivity
|
||||||
|
- ✅ Automatické uloženie GPS súradníc
|
||||||
|
- ✅ Mapa na detaile aktivity s názvom miesta
|
||||||
|
- ✅ "Otvoriť v Mapách" button (funguje na PC aj mobile)
|
||||||
|
- ✅ Deep linking do Google Maps/Apple Maps
|
||||||
|
- ✅ Custom styled autocomplete dropdown
|
||||||
- ⏸️ Mapa na zozname aktivít
|
- ⏸️ Mapa na zozname aktivít
|
||||||
- ⏸️ Mapa na detaile aktivity
|
|
||||||
- ⏸️ Klikateľné markery
|
- ⏸️ Klikateľné markery
|
||||||
- ⏸️ Navigácia do Google Maps
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -362,26 +383,26 @@ aby som nezabudol na termín
|
|||||||
|
|
||||||
## US-012: Pokročilé filtrovanie a preferencie
|
## US-012: Pokročilé filtrovanie a preferencie
|
||||||
|
|
||||||
**Status:** 📋 PLANNED
|
**Status:** <EFBFBD> WIP (Work In Progress)
|
||||||
|
|
||||||
Ako používateľ
|
Ako používateľ
|
||||||
chcem filtrovať aktivity podľa skúseností, pohlavia, veku, ceny a ďalších kritérií
|
chcem filtrovať aktivity podľa skúseností, pohlavia, veku, ceny a ďalších kritérií
|
||||||
aby som našiel aktivity, ktoré mi vyhovujú
|
aby som našiel aktivity, ktoré mi vyhovujú
|
||||||
|
|
||||||
**Vývojár:** -
|
**Vývojár:** Jozef Kovalčín
|
||||||
|
|
||||||
### Tasky:
|
### Tasky:
|
||||||
#### Rozšírenie databázového modelu
|
#### Rozšírenie databázového modelu
|
||||||
- ⏸️ Prisma schema: rozšírenie Activity (skillLevel, gender, minAge, maxAge, price)
|
- ✅ Prisma schema: rozšírenie Activity (skillLevel, gender, minAge, maxAge, price)
|
||||||
- ⏸️ Prisma schema: UserPreferences model (preferredSports, skillLevel, maxDistance, maxPrice)
|
- ✅ Prisma schema: UserPreferences model (preferredSports, skillLevel, maxDistance, maxPrice)
|
||||||
- ⏸️ Migrácia databázy
|
- ✅ Migrácia databázy
|
||||||
|
|
||||||
#### Backend
|
#### Backend
|
||||||
- ⏸️ API: GET /api/activities s rozšíreným filtrovaním
|
- ✅ API: GET /api/activities s rozšíreným filtrovaním
|
||||||
- ⏸️ Filtrovanie: skillLevel (začiatočník, stredne pokročilý, pokročilý, expert)
|
- ✅ Filtrovanie: skillLevel (začiatočník, stredne pokročilý, pokročilý, expert)
|
||||||
- ⏸️ Filtrovanie: gender (muži, ženy, zmiešané)
|
- ✅ Filtrovanie: gender (muži, ženy, zmiešané)
|
||||||
- ⏸️ Filtrovanie: vekové rozpätie (minAge-maxAge)
|
- ✅ Filtrovanie: vekové rozpätie (minAge-maxAge)
|
||||||
- ⏸️ Filtrovanie: cena (od-do)
|
- ✅ Filtrovanie: cena (od-do)
|
||||||
- ⏸️ API: GET/PUT /api/preferences (uloženie používateľských preferencií)
|
- ⏸️ API: GET/PUT /api/preferences (uloženie používateľských preferencií)
|
||||||
|
|
||||||
#### Frontend - Filter panel
|
#### Frontend - Filter panel
|
||||||
@ -397,13 +418,20 @@ aby som našiel aktivity, ktoré mi vyhovujú
|
|||||||
- ⏸️ Mobile-friendly filter (bottom sheet/modal)
|
- ⏸️ Mobile-friendly filter (bottom sheet/modal)
|
||||||
|
|
||||||
#### Formulár na vytvorenie aktivity
|
#### Formulár na vytvorenie aktivity
|
||||||
- ⏸️ Pridať polia: skillLevel, gender, minAge, maxAge do create formu
|
- ✅ Pridať polia: skillLevel, gender, minAge, maxAge, price do create formu
|
||||||
- ⏸️ Validácia (minAge <= maxAge, price >= 0)
|
- ✅ Custom styled number inputs s +/- tlačidlami
|
||||||
|
- ✅ Validácia (minAge <= maxAge, price >= 0)
|
||||||
|
- ✅ Backend validácia všetkých polí
|
||||||
|
- ✅ Zobrazenie nových polí na detail stránke
|
||||||
|
|
||||||
### Výsledné funkcie:
|
### Výsledné funkcie:
|
||||||
- ⏸️ Filtrovanie podľa všetkých kritérií
|
- ✅ Databázový model rozšírený
|
||||||
|
- ✅ Backend API podporuje filtrovanie
|
||||||
|
- ✅ Rozšírený create form s všetkými poliami
|
||||||
|
- ✅ Custom UI controls (duration, participants, age, price)
|
||||||
|
- ✅ Detail stránka zobrazuje všetky nové polia
|
||||||
|
- ⏸️ Filter panel UI na /activities
|
||||||
- ⏸️ Uloženie preferencií
|
- ⏸️ Uloženie preferencií
|
||||||
- ⏸️ Rozšírený create form
|
|
||||||
- ⏸️ Responzívny filter UI
|
- ⏸️ Responzívny filter UI
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -0,0 +1,270 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SportType" AS ENUM ('FOOTBALL', 'BASKETBALL', 'TENNIS', 'VOLLEYBALL', 'BADMINTON', 'TABLE_TENNIS', 'RUNNING', 'CYCLING', 'SWIMMING', 'GYM', 'OTHER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SkillLevel" AS ENUM ('BEGINNER', 'INTERMEDIATE', 'ADVANCED', 'EXPERT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ActivityStatus" AS ENUM ('OPEN', 'FULL', 'CANCELLED', 'COMPLETED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ParticipationStatus" AS ENUM ('CONFIRMED', 'PENDING', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"image" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Profile" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"bio" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"city" TEXT,
|
||||||
|
"skillLevel" "SkillLevel" NOT NULL DEFAULT 'BEGINNER',
|
||||||
|
"favoriteSports" "SportType"[],
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Profile_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"ipAddress" TEXT,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Account" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"providerId" TEXT NOT NULL,
|
||||||
|
"accessToken" TEXT,
|
||||||
|
"refreshToken" TEXT,
|
||||||
|
"idToken" TEXT,
|
||||||
|
"expiresAt" TIMESTAMP(3),
|
||||||
|
"password" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Verification" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Verification_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PasswordReset" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"used" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "PasswordReset_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Venue" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"address" TEXT NOT NULL,
|
||||||
|
"city" TEXT NOT NULL,
|
||||||
|
"latitude" DOUBLE PRECISION,
|
||||||
|
"longitude" DOUBLE PRECISION,
|
||||||
|
"sportTypes" "SportType"[],
|
||||||
|
"amenities" TEXT[],
|
||||||
|
"priceRange" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"website" TEXT,
|
||||||
|
"image" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Venue_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Activity" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"sportType" "SportType" NOT NULL,
|
||||||
|
"skillLevel" "SkillLevel" NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"duration" INTEGER NOT NULL,
|
||||||
|
"maxParticipants" INTEGER NOT NULL,
|
||||||
|
"currentParticipants" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"status" "ActivityStatus" NOT NULL DEFAULT 'OPEN',
|
||||||
|
"isPublic" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"location" TEXT NOT NULL,
|
||||||
|
"locationName" TEXT,
|
||||||
|
"latitude" DOUBLE PRECISION,
|
||||||
|
"longitude" DOUBLE PRECISION,
|
||||||
|
"venueId" TEXT,
|
||||||
|
"organizerId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Activity_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Participation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"activityId" TEXT NOT NULL,
|
||||||
|
"status" "ParticipationStatus" NOT NULL DEFAULT 'CONFIRMED',
|
||||||
|
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Participation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Review" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"rating" INTEGER NOT NULL,
|
||||||
|
"comment" TEXT,
|
||||||
|
"venueId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Review_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VenueFavorite" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"venueId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "VenueFavorite_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Profile_userId_key" ON "Profile"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Account_providerId_accountId_key" ON "Account"("providerId", "accountId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Verification_identifier_value_key" ON "Verification"("identifier", "value");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PasswordReset_token_key" ON "PasswordReset"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PasswordReset_token_idx" ON "PasswordReset"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PasswordReset_userId_idx" ON "PasswordReset"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Venue_city_idx" ON "Venue"("city");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Venue_sportTypes_idx" ON "Venue"("sportTypes");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Activity_sportType_idx" ON "Activity"("sportType");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Activity_date_idx" ON "Activity"("date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Activity_status_idx" ON "Activity"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Activity_venueId_idx" ON "Activity"("venueId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Participation_activityId_idx" ON "Participation"("activityId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Participation_userId_activityId_key" ON "Participation"("userId", "activityId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Review_venueId_idx" ON "Review"("venueId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Review_userId_idx" ON "Review"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VenueFavorite_userId_idx" ON "VenueFavorite"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "VenueFavorite_userId_venueId_key" ON "VenueFavorite"("userId", "venueId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Profile" ADD CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PasswordReset" ADD CONSTRAINT "PasswordReset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_organizerId_fkey" FOREIGN KEY ("organizerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Participation" ADD CONSTRAINT "Participation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Participation" ADD CONSTRAINT "Participation_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Review" ADD CONSTRAINT "Review_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Review" ADD CONSTRAINT "Review_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VenueFavorite" ADD CONSTRAINT "VenueFavorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VenueFavorite" ADD CONSTRAINT "VenueFavorite_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "GenderPreference" AS ENUM ('MALE', 'FEMALE', 'MIXED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Activity" ADD COLUMN "gender" "GenderPreference" NOT NULL DEFAULT 'MIXED',
|
||||||
|
ADD COLUMN "maxAge" INTEGER NOT NULL DEFAULT 99,
|
||||||
|
ADD COLUMN "minAge" INTEGER NOT NULL DEFAULT 18,
|
||||||
|
ADD COLUMN "price" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Activity_gender_idx" ON "Activity"("gender");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Activity_skillLevel_idx" ON "Activity"("skillLevel");
|
||||||
3
apps/backend/prisma/migrations/migration_lock.toml
Normal file
3
apps/backend/prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@ -135,6 +135,13 @@ enum ActivityStatus {
|
|||||||
COMPLETED
|
COMPLETED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gender preference
|
||||||
|
enum GenderPreference {
|
||||||
|
MALE
|
||||||
|
FEMALE
|
||||||
|
MIXED
|
||||||
|
}
|
||||||
|
|
||||||
// Participation status
|
// Participation status
|
||||||
enum ParticipationStatus {
|
enum ParticipationStatus {
|
||||||
CONFIRMED
|
CONFIRMED
|
||||||
@ -182,13 +189,27 @@ model Activity {
|
|||||||
currentParticipants Int @default(0)
|
currentParticipants Int @default(0)
|
||||||
status ActivityStatus @default(OPEN)
|
status ActivityStatus @default(OPEN)
|
||||||
isPublic Boolean @default(true)
|
isPublic Boolean @default(true)
|
||||||
venueId String
|
|
||||||
|
// Filter fields for US-012
|
||||||
|
gender GenderPreference @default(MIXED)
|
||||||
|
minAge Int @default(18)
|
||||||
|
maxAge Int @default(99)
|
||||||
|
price Float @default(0) // in EUR, 0 = free
|
||||||
|
|
||||||
|
// Location fields - direct address input
|
||||||
|
location String // Full address from Google Maps
|
||||||
|
locationName String? // Optional custom name (e.g., "Park na Kolibe")
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
|
||||||
|
// Venue is now optional for backward compatibility
|
||||||
|
venueId String?
|
||||||
organizerId String
|
organizerId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
venue Venue? @relation(fields: [venueId], references: [id], onDelete: SetNull)
|
||||||
organizer User @relation(fields: [organizerId], references: [id], onDelete: Cascade)
|
organizer User @relation(fields: [organizerId], references: [id], onDelete: Cascade)
|
||||||
participations Participation[]
|
participations Participation[]
|
||||||
|
|
||||||
@ -196,6 +217,8 @@ model Activity {
|
|||||||
@@index([date])
|
@@index([date])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([venueId])
|
@@index([venueId])
|
||||||
|
@@index([gender])
|
||||||
|
@@index([skillLevel])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Participation (Účasť na aktivite) model
|
// Participation (Účasť na aktivite) model
|
||||||
|
|||||||
@ -24,8 +24,19 @@ const activitySchema = z.object({
|
|||||||
date: z.string().datetime(),
|
date: z.string().datetime(),
|
||||||
duration: z.number().min(15).max(480),
|
duration: z.number().min(15).max(480),
|
||||||
maxParticipants: z.number().min(2).max(50),
|
maxParticipants: z.number().min(2).max(50),
|
||||||
venueId: z.string(),
|
location: z.string().min(1), // Google Maps address
|
||||||
|
locationName: z.string().optional(), // Custom location name
|
||||||
|
latitude: z.number().optional(),
|
||||||
|
longitude: z.number().optional(),
|
||||||
|
venueId: z.string().optional(), // Now optional
|
||||||
|
gender: z.enum(["MALE", "FEMALE", "MIXED"]).default("MIXED"),
|
||||||
|
minAge: z.number().min(6).max(99).default(18),
|
||||||
|
maxAge: z.number().min(6).max(99).default(99),
|
||||||
|
price: z.number().min(0).default(0),
|
||||||
isPublic: z.boolean().default(true),
|
isPublic: z.boolean().default(true),
|
||||||
|
}).refine((data) => data.minAge <= data.maxAge, {
|
||||||
|
message: "Minimálny vek musí byť menší alebo rovný maximálnemu veku",
|
||||||
|
path: ["minAge"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/activities - Get all activities
|
// GET /api/activities - Get all activities
|
||||||
@ -35,11 +46,23 @@ export async function GET(request: NextRequest) {
|
|||||||
const sportType = searchParams.get("sportType");
|
const sportType = searchParams.get("sportType");
|
||||||
const city = searchParams.get("city");
|
const city = searchParams.get("city");
|
||||||
const status = searchParams.get("status") || "OPEN";
|
const status = searchParams.get("status") || "OPEN";
|
||||||
|
const skillLevel = searchParams.get("skillLevel");
|
||||||
|
const gender = searchParams.get("gender");
|
||||||
|
const minPrice = searchParams.get("minPrice");
|
||||||
|
const maxPrice = searchParams.get("maxPrice");
|
||||||
|
const minAge = searchParams.get("minAge");
|
||||||
|
const maxAge = searchParams.get("maxAge");
|
||||||
|
|
||||||
const activities = await prisma.activity.findMany({
|
const activities = await prisma.activity.findMany({
|
||||||
where: {
|
where: {
|
||||||
...(sportType && { sportType: sportType as any }),
|
...(sportType && { sportType: sportType as any }),
|
||||||
status: status as any,
|
status: status as any,
|
||||||
|
...(skillLevel && { skillLevel: skillLevel as any }),
|
||||||
|
...(gender && { gender: gender as any }),
|
||||||
|
...(minPrice && { price: { gte: parseFloat(minPrice) } }),
|
||||||
|
...(maxPrice && { price: { lte: parseFloat(maxPrice) } }),
|
||||||
|
...(minAge && { maxAge: { gte: parseInt(minAge) } }), // User age >= activity minAge
|
||||||
|
...(maxAge && { minAge: { lte: parseInt(maxAge) } }), // User age <= activity maxAge
|
||||||
...(city && {
|
...(city && {
|
||||||
venue: {
|
venue: {
|
||||||
city: city,
|
city: city,
|
||||||
|
|||||||
@ -17,14 +17,22 @@ interface Activity {
|
|||||||
maxParticipants: number;
|
maxParticipants: number;
|
||||||
currentParticipants: number;
|
currentParticipants: number;
|
||||||
status: string;
|
status: string;
|
||||||
venue: {
|
location: string;
|
||||||
|
locationName: string | null;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
gender: string;
|
||||||
|
minAge: number;
|
||||||
|
maxAge: number;
|
||||||
|
price: number;
|
||||||
|
venue?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
city: string;
|
city: string;
|
||||||
address: string;
|
address: string;
|
||||||
latitude: number | null;
|
latitude: number | null;
|
||||||
longitude: number | null;
|
longitude: number | null;
|
||||||
};
|
} | null;
|
||||||
organizer: {
|
organizer: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -62,6 +70,12 @@ const skillLevelLabels: Record<string, string> = {
|
|||||||
EXPERT: "Expert",
|
EXPERT: "Expert",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const genderLabels: Record<string, string> = {
|
||||||
|
MALE: "Muži",
|
||||||
|
FEMALE: "Ženy",
|
||||||
|
MIXED: "Zmiešané",
|
||||||
|
};
|
||||||
|
|
||||||
const statusLabels: Record<string, { label: string; color: string }> = {
|
const statusLabels: Record<string, { label: string; color: string }> = {
|
||||||
OPEN: { label: "Otvorená", color: "bg-green-500" },
|
OPEN: { label: "Otvorená", color: "bg-green-500" },
|
||||||
FULL: { label: "Plná", color: "bg-orange-500" },
|
FULL: { label: "Plná", color: "bg-orange-500" },
|
||||||
@ -317,14 +331,36 @@ export default function ActivityDetailPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-[color:var(--fluent-text-secondary)] mb-1">
|
<p className="text-sm text-[color:var(--fluent-text-secondary)] mb-1">
|
||||||
Športovisko
|
Miesto konania
|
||||||
</p>
|
</p>
|
||||||
<p className="text-lg text-[color:var(--fluent-text)]">
|
{activity.locationName && (
|
||||||
📍 {activity.venue.name}
|
<p className="text-lg font-medium text-[color:var(--fluent-text)]">
|
||||||
</p>
|
📍 {activity.locationName}
|
||||||
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
|
</p>
|
||||||
{activity.venue.address}, {activity.venue.city}
|
)}
|
||||||
|
<p className={`${activity.locationName ? 'text-sm' : 'text-lg'} text-[color:var(--fluent-text-secondary)]`}>
|
||||||
|
{activity.location}
|
||||||
</p>
|
</p>
|
||||||
|
{(activity.locationName || activity.location || (activity.latitude && activity.longitude)) && (
|
||||||
|
<a
|
||||||
|
href={
|
||||||
|
activity.locationName
|
||||||
|
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(activity.locationName + ', ' + activity.location)}`
|
||||||
|
: activity.location
|
||||||
|
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(activity.location)}`
|
||||||
|
: `https://www.google.com/maps/search/?api=1&query=${activity.latitude},${activity.longitude}`
|
||||||
|
}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 mt-2 px-4 py-2 bg-[color:var(--fluent-accent)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm font-medium"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||||
|
<circle cx="12" cy="10" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
Otvoriť v Mapách
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -337,6 +373,36 @@ export default function ActivityDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[color:var(--fluent-text-secondary)] mb-1">
|
||||||
|
Pohlavie
|
||||||
|
</p>
|
||||||
|
<p className="text-lg text-[color:var(--fluent-text)]">
|
||||||
|
{genderLabels[activity.gender] || activity.gender}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[color:var(--fluent-text-secondary)] mb-1">
|
||||||
|
Vekové rozpätie
|
||||||
|
</p>
|
||||||
|
<p className="text-lg text-[color:var(--fluent-text)]">
|
||||||
|
{activity.minAge} - {activity.maxAge} rokov
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activity.price > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[color:var(--fluent-text-secondary)] mb-1">
|
||||||
|
Cena
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold text-[color:var(--fluent-accent)]">
|
||||||
|
{activity.price.toFixed(2)} €
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activity.description && (
|
{activity.description && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-[color:var(--fluent-text-secondary)] mb-1">
|
<p className="text-sm text-[color:var(--fluent-text-secondary)] mb-1">
|
||||||
@ -352,20 +418,26 @@ export default function ActivityDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
{activity.venue.latitude && activity.venue.longitude && (
|
{activity.latitude && activity.longitude && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Mapa</CardTitle>
|
<CardTitle>Mapa</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="aspect-video w-full rounded-lg overflow-hidden">
|
<div className="aspect-video w-full rounded-lg overflow-hidden bg-[color:var(--fluent-surface-secondary)]">
|
||||||
<iframe
|
<iframe
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
frameBorder="0"
|
frameBorder="0"
|
||||||
style={{ border: 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`}
|
src={
|
||||||
|
activity.locationName
|
||||||
|
? `https://maps.google.com/maps?q=${encodeURIComponent(activity.locationName + ', ' + activity.location)}&t=&z=15&ie=UTF8&iwloc=&output=embed`
|
||||||
|
: `https://maps.google.com/maps?q=${activity.latitude},${activity.longitude}&t=&z=15&ie=UTF8&iwloc=&output=embed`
|
||||||
|
}
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -1,18 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { LocationPicker } from "@/components/LocationPicker";
|
||||||
interface Venue {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
city: string;
|
|
||||||
sportTypes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const sportTypes = [
|
const sportTypes = [
|
||||||
{ value: "FOOTBALL", label: "Futbal" },
|
{ value: "FOOTBALL", label: "Futbal" },
|
||||||
@ -35,11 +28,15 @@ const skillLevels = [
|
|||||||
{ value: "EXPERT", label: "Expert" },
|
{ value: "EXPERT", label: "Expert" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const genderOptions = [
|
||||||
|
{ value: "MIXED", label: "Bez preferencie" },
|
||||||
|
{ value: "MALE", label: "Muži" },
|
||||||
|
{ value: "FEMALE", label: "Ženy" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function CreateActivityPage() {
|
export default function CreateActivityPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [venues, setVenues] = useState<Venue[]>([]);
|
|
||||||
const [loadingVenues, setLoadingVenues] = useState(true);
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@ -51,35 +48,33 @@ export default function CreateActivityPage() {
|
|||||||
time: "",
|
time: "",
|
||||||
duration: 90,
|
duration: 90,
|
||||||
maxParticipants: 10,
|
maxParticipants: 10,
|
||||||
venueId: "",
|
gender: "MIXED" as "MALE" | "FEMALE" | "MIXED",
|
||||||
|
minAge: 18,
|
||||||
|
maxAge: 99,
|
||||||
|
price: 0,
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const [location, setLocation] = useState({
|
||||||
fetchVenues();
|
address: "",
|
||||||
}, []);
|
name: "",
|
||||||
|
latitude: undefined as number | undefined,
|
||||||
const fetchVenues = async () => {
|
longitude: undefined as number | undefined,
|
||||||
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!location.address) {
|
||||||
|
setError("Prosím zadajte adresu aktivity");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.minAge > formData.maxAge) {
|
||||||
|
setError("Minimálny vek nemôže byť väčší ako maximálny vek");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
@ -98,6 +93,14 @@ export default function CreateActivityPage() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...formData,
|
...formData,
|
||||||
date: dateTime.toISOString(),
|
date: dateTime.toISOString(),
|
||||||
|
location: location.address,
|
||||||
|
locationName: location.name || undefined,
|
||||||
|
latitude: location.latitude,
|
||||||
|
longitude: location.longitude,
|
||||||
|
gender: formData.gender,
|
||||||
|
minAge: formData.minAge,
|
||||||
|
maxAge: formData.maxAge,
|
||||||
|
price: formData.price,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -133,18 +136,6 @@ export default function CreateActivityPage() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
@ -233,57 +224,272 @@ export default function CreateActivityPage() {
|
|||||||
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
||||||
Dĺžka trvania (minúty) *
|
Dĺžka trvania (minúty) *
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<div className="relative flex items-center">
|
||||||
type="number"
|
<button
|
||||||
name="duration"
|
type="button"
|
||||||
value={formData.duration}
|
onClick={() =>
|
||||||
onChange={handleChange}
|
setFormData((prev) => ({
|
||||||
required
|
...prev,
|
||||||
min={15}
|
duration: Math.max(15, prev.duration - 15),
|
||||||
max={480}
|
}))
|
||||||
/>
|
}
|
||||||
|
className="absolute left-0 h-full px-3 text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-surface)] rounded-l-lg transition-colors z-10"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="duration"
|
||||||
|
value={formData.duration}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
min={15}
|
||||||
|
max={480}
|
||||||
|
step={15}
|
||||||
|
className="text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
duration: Math.min(480, prev.duration + 15),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="absolute right-0 h-full px-3 text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-surface)] rounded-r-lg transition-colors z-10"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
||||||
Max počet hráčov *
|
Max počet hráčov *
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<div className="relative flex items-center">
|
||||||
type="number"
|
<button
|
||||||
name="maxParticipants"
|
type="button"
|
||||||
value={formData.maxParticipants}
|
onClick={() =>
|
||||||
onChange={handleChange}
|
setFormData((prev) => ({
|
||||||
required
|
...prev,
|
||||||
min={2}
|
maxParticipants: Math.max(2, prev.maxParticipants - 1),
|
||||||
max={50}
|
}))
|
||||||
/>
|
}
|
||||||
|
className="absolute left-0 h-full px-3 text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-surface)] rounded-l-lg transition-colors z-10"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="maxParticipants"
|
||||||
|
value={formData.maxParticipants}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
min={2}
|
||||||
|
max={50}
|
||||||
|
className="text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
maxParticipants: Math.min(50, prev.maxParticipants + 1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="absolute right-0 h-full px-3 text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-surface)] rounded-r-lg transition-colors z-10"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Športovisko */}
|
{/* Miesto konania */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<LocationPicker
|
||||||
|
value={location}
|
||||||
|
onChange={setLocation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pohlavie */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
||||||
Športovisko *
|
Pohlavie *
|
||||||
</label>
|
</label>
|
||||||
{venues.length === 0 ? (
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
|
{genderOptions.map((option) => (
|
||||||
Žiadne športoviská nie sú dostupné. Kontaktujte
|
<label
|
||||||
administrátora.
|
key={option.value}
|
||||||
</p>
|
className={`
|
||||||
) : (
|
flex items-center justify-center px-4 py-3 rounded-lg border-2 cursor-pointer transition-all
|
||||||
<select
|
${
|
||||||
name="venueId"
|
formData.gender === option.value
|
||||||
value={formData.venueId}
|
? 'border-[color:var(--fluent-accent)] bg-[color:var(--fluent-accent)]/10 text-[color:var(--fluent-accent)] font-semibold'
|
||||||
onChange={handleChange}
|
: 'border-[color:var(--fluent-border)] bg-[color:var(--fluent-surface-secondary)] text-[color:var(--fluent-text)] hover:border-[color:var(--fluent-border-strong)]'
|
||||||
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) => (
|
<input
|
||||||
<option key={venue.id} value={venue.id}>
|
type="radio"
|
||||||
{venue.name} - {venue.city}
|
name="gender"
|
||||||
</option>
|
value={option.value}
|
||||||
))}
|
checked={formData.gender === option.value}
|
||||||
</select>
|
onChange={handleChange}
|
||||||
)}
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vekové rozpätie a cena */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
||||||
|
Min. vek *
|
||||||
|
</label>
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
minAge: Math.max(6, prev.minAge - 1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="absolute left-0 h-full px-3 text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-surface)] rounded-l-lg transition-colors z-10"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="minAge"
|
||||||
|
value={formData.minAge}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
min={6}
|
||||||
|
max={99}
|
||||||
|
className="text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
minAge: Math.min(99, prev.minAge + 1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="absolute right-0 h-full px-3 text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-surface)] rounded-r-lg transition-colors z-10"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
||||||
|
Max. vek *
|
||||||
|
</label>
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
maxAge: Math.max(6, prev.maxAge - 1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="absolute left-0 h-full px-3 text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-surface)] rounded-l-lg transition-colors z-10"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="maxAge"
|
||||||
|
value={formData.maxAge}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
min={6}
|
||||||
|
max={99}
|
||||||
|
className="text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
maxAge: Math.min(99, prev.maxAge + 1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="absolute right-0 h-full px-3 text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-surface)] rounded-r-lg transition-colors z-10"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
||||||
|
Cena (€)
|
||||||
|
</label>
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
price: Math.max(0, prev.price - 0.5),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="absolute left-0 h-full px-3 text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-surface)] rounded-l-lg transition-colors z-10"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="price"
|
||||||
|
value={formData.price}
|
||||||
|
onChange={handleChange}
|
||||||
|
min={0}
|
||||||
|
step={0.5}
|
||||||
|
placeholder="0 = zadarmo"
|
||||||
|
className="text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
price: Math.round((prev.price + 0.5) * 10) / 10,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="absolute right-0 h-full px-3 text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-surface)] rounded-r-lg transition-colors z-10"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Úroveň */}
|
{/* Úroveň */}
|
||||||
@ -350,7 +556,7 @@ export default function CreateActivityPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={loading || venues.length === 0}
|
disabled={loading || !location.address}
|
||||||
>
|
>
|
||||||
{loading ? "Vytváranie..." : "Vytvoriť aktivitu"}
|
{loading ? "Vytváranie..." : "Vytvoriť aktivitu"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -16,12 +16,16 @@ interface Activity {
|
|||||||
maxParticipants: number;
|
maxParticipants: number;
|
||||||
currentParticipants: number;
|
currentParticipants: number;
|
||||||
status: string;
|
status: string;
|
||||||
venue: {
|
location: string;
|
||||||
|
locationName: string | null;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
venue?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
city: string;
|
city: string;
|
||||||
address: string;
|
address: string;
|
||||||
};
|
} | null;
|
||||||
organizer: {
|
organizer: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -105,7 +109,7 @@ function ActivityCard({ activity }: { activity: Activity }) {
|
|||||||
{/* Location */}
|
{/* Location */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<p className="text-sm text-[color:var(--fluent-text)]">
|
<p className="text-sm text-[color:var(--fluent-text)]">
|
||||||
📍 {activity.venue.name}, {activity.venue.city}
|
📍 {activity.locationName || activity.location}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -4,9 +4,11 @@ import { useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { useSession } from "@/lib/auth-client";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [selectedSport, setSelectedSport] = useState<string | null>(null);
|
const [selectedSport, setSelectedSport] = useState<string | null>(null);
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const sports = [
|
const sports = [
|
||||||
{
|
{
|
||||||
@ -209,32 +211,64 @@ export default function Home() {
|
|||||||
style={{ boxShadow: "var(--shadow-xl)" }}
|
style={{ boxShadow: "var(--shadow-xl)" }}
|
||||||
>
|
>
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<h2 className="text-3xl font-bold mb-4 text-[color:var(--fluent-text)]">
|
{session ? (
|
||||||
Pripravení začať?
|
<>
|
||||||
</h2>
|
<h2 className="text-3xl font-bold mb-4 text-[color:var(--fluent-text)]">
|
||||||
<p className="text-[color:var(--fluent-text-secondary)] mb-8 max-w-2xl mx-auto text-lg">
|
Objavte nové športové zážitky
|
||||||
Zaregistrujte sa ešte dnes a staňte sa súčasťou aktívnej športovej
|
</h2>
|
||||||
komunity.
|
<p className="text-[color:var(--fluent-text-secondary)] mb-8 max-w-2xl mx-auto text-lg">
|
||||||
</p>
|
Preskúmajte aktivity vo vašom okolí a pripojte sa k športovej komunite.
|
||||||
<div className="flex gap-4 justify-center flex-wrap">
|
</p>
|
||||||
<Link href="/auth/signup">
|
<div className="flex gap-4 justify-center flex-wrap">
|
||||||
<Button
|
<Link href="/activities">
|
||||||
size="lg"
|
<Button
|
||||||
className="!bg-[color:var(--fluent-accent)] !text-white hover:!bg-[color:var(--fluent-accent-hover)] font-bold text-lg px-8 py-6 shadow-lg hover:shadow-xl transition-all"
|
size="lg"
|
||||||
>
|
className="!bg-[color:var(--fluent-accent)] !text-white hover:!bg-[color:var(--fluent-accent-hover)] font-bold text-lg px-8 py-6 shadow-lg hover:shadow-xl transition-all"
|
||||||
Vytvoriť účet zadarmo
|
>
|
||||||
</Button>
|
Preskúmať aktivity
|
||||||
</Link>
|
</Button>
|
||||||
<Link href="/activities">
|
</Link>
|
||||||
<Button
|
<Link href="/activities/create">
|
||||||
size="lg"
|
<Button
|
||||||
variant="outline"
|
size="lg"
|
||||||
className="border-2 border-[color:var(--fluent-border-strong)] !text-[color:var(--fluent-text)] hover:!bg-[color:var(--fluent-surface)] font-semibold text-lg px-8 py-6"
|
variant="outline"
|
||||||
>
|
className="border-2 border-[color:var(--fluent-border-strong)] !text-[color:var(--fluent-text)] hover:!bg-[color:var(--fluent-surface)] font-semibold text-lg px-8 py-6"
|
||||||
Preskúmať aktivity
|
>
|
||||||
</Button>
|
Vytvoriť aktivitu
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 className="text-3xl font-bold mb-4 text-[color:var(--fluent-text)]">
|
||||||
|
Pripravení začať?
|
||||||
|
</h2>
|
||||||
|
<p className="text-[color:var(--fluent-text-secondary)] mb-8 max-w-2xl mx-auto text-lg">
|
||||||
|
Zaregistrujte sa ešte dnes a staňte sa súčasťou aktívnej športovej
|
||||||
|
komunity.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 justify-center flex-wrap">
|
||||||
|
<Link href="/auth/signup">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="!bg-[color:var(--fluent-accent)] !text-white hover:!bg-[color:var(--fluent-accent-hover)] font-bold text-lg px-8 py-6 shadow-lg hover:shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
Vytvoriť účet zadarmo
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/activities">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
className="border-2 border-[color:var(--fluent-border-strong)] !text-[color:var(--fluent-text)] hover:!bg-[color:var(--fluent-surface)] font-semibold text-lg px-8 py-6"
|
||||||
|
>
|
||||||
|
Preskúmať aktivity
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
273
apps/frontend/src/components/LocationPicker.tsx
Normal file
273
apps/frontend/src/components/LocationPicker.tsx
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { Input } from "./ui/Input";
|
||||||
|
|
||||||
|
interface LocationPickerProps {
|
||||||
|
value: {
|
||||||
|
address: string;
|
||||||
|
name?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
};
|
||||||
|
onChange: (location: {
|
||||||
|
address: string;
|
||||||
|
name?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocationPicker({ value, onChange }: LocationPickerProps) {
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const autocompleteRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
setError("Google Maps API kľúč nie je nastavený. Pridajte NEXT_PUBLIC_GOOGLE_MAPS_API_KEY do .env");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already loaded
|
||||||
|
if (window.google?.maps?.places) {
|
||||||
|
setIsLoaded(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Google Maps script
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`;
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
|
||||||
|
script.onload = () => {
|
||||||
|
// Wait a bit for libraries to fully load
|
||||||
|
setTimeout(() => {
|
||||||
|
if (window.google?.maps?.places) {
|
||||||
|
setIsLoaded(true);
|
||||||
|
} else {
|
||||||
|
setError("Google Maps sa načítalo, ale Places API nie je k dispozícii");
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
script.onerror = () => setError("Nepodarilo sa načítať Google Maps");
|
||||||
|
|
||||||
|
document.head.appendChild(script);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (script.parentNode) {
|
||||||
|
script.parentNode.removeChild(script);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoaded || !autocompleteRef.current) return;
|
||||||
|
|
||||||
|
// Double check that google.maps.places is available
|
||||||
|
if (!window.google?.maps?.places?.Autocomplete) {
|
||||||
|
console.error('Google Maps Places API not available');
|
||||||
|
setError('Google Maps Places API nie je k dispozícii. Použite manuálny vstup nižšie.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create simple input element
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.placeholder = 'Začnite písať adresu...';
|
||||||
|
input.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)]';
|
||||||
|
input.value = value.address;
|
||||||
|
|
||||||
|
// Clear container and add input
|
||||||
|
autocompleteRef.current.innerHTML = '';
|
||||||
|
autocompleteRef.current.appendChild(input);
|
||||||
|
|
||||||
|
// Use classic Autocomplete (still supported, just deprecated warning)
|
||||||
|
const autocomplete = new google.maps.places.Autocomplete(input, {
|
||||||
|
componentRestrictions: { country: 'sk' },
|
||||||
|
fields: ['formatted_address', 'geometry', 'name'],
|
||||||
|
});
|
||||||
|
|
||||||
|
autocomplete.addListener('place_changed', () => {
|
||||||
|
const place = autocomplete.getPlace();
|
||||||
|
|
||||||
|
if (!place.geometry || !place.geometry.location) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
address: place.formatted_address || '',
|
||||||
|
name: place.name !== place.formatted_address ? place.name : undefined,
|
||||||
|
latitude: place.geometry.location.lat(),
|
||||||
|
longitude: place.geometry.location.lng(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add custom styles for the autocomplete dropdown
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.pac-container {
|
||||||
|
background-color: var(--fluent-surface-secondary);
|
||||||
|
border: 1px solid var(--fluent-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pac-container:after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.pac-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--fluent-divider);
|
||||||
|
color: var(--fluent-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.pac-item:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
.pac-item:hover {
|
||||||
|
background-color: var(--fluent-surface);
|
||||||
|
}
|
||||||
|
.pac-item-selected {
|
||||||
|
background-color: var(--fluent-accent-light);
|
||||||
|
}
|
||||||
|
.pac-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.pac-item-query {
|
||||||
|
color: var(--fluent-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.pac-matched {
|
||||||
|
color: var(--fluent-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Autocomplete error:', err);
|
||||||
|
setError('Chyba pri inicializácii Google Maps. Použite manuálny vstup nižšie.');
|
||||||
|
}
|
||||||
|
}, [isLoaded, onChange]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-yellow-500/10 border border-yellow-500/50 rounded-lg text-yellow-600 dark:text-yellow-400">
|
||||||
|
<p className="text-sm font-semibold mb-2">{error}</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
Pre aktiváciu Places API:
|
||||||
|
</p>
|
||||||
|
<ol className="text-xs mt-2 ml-4 list-decimal space-y-1">
|
||||||
|
<li>Otvorte <a href="https://console.cloud.google.com/apis/library/places-backend.googleapis.com" target="_blank" className="underline">Google Cloud Console</a></li>
|
||||||
|
<li>Aktivujte "Places API (New)"</li>
|
||||||
|
<li>Obnovte stránku</li>
|
||||||
|
</ol>
|
||||||
|
<p className="text-xs mt-3">Manuálny vstup:</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
||||||
|
Adresa *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={value.address}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...value, address: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="napr. Hlavná 1, Bratislava"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
||||||
|
Názov miesta (voliteľné)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={value.name || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...value, name: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="napr. Park na Kolibe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoaded) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-[color:var(--fluent-surface-secondary)] rounded-lg">
|
||||||
|
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
|
||||||
|
Načítavam Google Maps...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
||||||
|
Vyhľadajte adresu *
|
||||||
|
</label>
|
||||||
|
<div ref={autocompleteRef}></div>
|
||||||
|
<p className="mt-2 text-xs text-[color:var(--fluent-text-tertiary)]">
|
||||||
|
Začnite písať a vyberte adresu zo zoznamu návrhov
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{value.address && (
|
||||||
|
<div className="p-3 bg-[color:var(--fluent-surface-secondary)] rounded-lg border border-[color:var(--fluent-border)]">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-[color:var(--fluent-accent)] flex-shrink-0 mt-0.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1">
|
||||||
|
{value.name && (
|
||||||
|
<p className="text-sm font-medium text-[color:var(--fluent-text)]">
|
||||||
|
{value.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
|
||||||
|
{value.address}
|
||||||
|
</p>
|
||||||
|
{value.latitude && value.longitude && (
|
||||||
|
<p className="text-xs text-[color:var(--fluent-text-tertiary)] mt-1">
|
||||||
|
GPS: {value.latitude.toFixed(6)}, {value.longitude.toFixed(6)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/frontend/src/types/google-maps.d.ts
vendored
Normal file
9
apps/frontend/src/types/google-maps.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="google.maps" />
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
google: typeof google;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
Loading…
Reference in New Issue
Block a user