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
|
||||
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)
|
||||
# Get API key from: https://app.brevo.com/settings/keys/api
|
||||
# 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
|
||||
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é)
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
@ -23,12 +23,14 @@ aby som mohol používať aplikáciu
|
||||
- ✅ Hash hesla (scrypt via Better Auth)
|
||||
- ✅ Session management (localStorage)
|
||||
- ✅ Responzívny dizajn formulárov
|
||||
- ✅ Podmienené zobrazenie "Vytvoriť účet" button (skrytie pre prihlásených) - Jozef Kovalčín
|
||||
|
||||
### Výsledné funkcie:
|
||||
- ✅ Fungujúca registrácia
|
||||
- ✅ Fungujúce prihlásenie
|
||||
- ✅ Session persistence
|
||||
- ✅ 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
|
||||
- ✅ Redirect na detail po vytvorení
|
||||
- ✅ 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:
|
||||
- ✅ API endpoint funguje
|
||||
@ -105,6 +111,9 @@ aby som našiel spoluhráčov
|
||||
- ✅ Frontend formulár implementovaný a funkčný
|
||||
- ✅ Automatické načítanie venues
|
||||
- ✅ 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
|
||||
- ✅ Progress bar obsadenosti
|
||||
- ✅ 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:
|
||||
- ✅ API endpoints fungujú
|
||||
- ✅ Zoznam aktivít (UI) s kartami
|
||||
- ✅ Detail aktivity s kompletnou informáciou
|
||||
- ✅ Mapa športoviska
|
||||
- ✅ Mapa športoviska s názvom lokality
|
||||
- ✅ Progress bar a zoznam účastníkov
|
||||
- ✅ 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
|
||||
|
||||
**Status:** 📋 PLANNED
|
||||
**Status:** ✅ HOTOVÉ
|
||||
|
||||
Ako používateľ
|
||||
chcem vidieť polohu aktivít na Google Maps
|
||||
aby som vedel, kde sa aktivita koná a ako ďaleko to mám
|
||||
|
||||
**Vývojár:** -
|
||||
**Vývojár:** Jozef Kovalčín
|
||||
|
||||
### Tasky:
|
||||
- ⏸️ Google Maps API setup (API key)
|
||||
- ⏸️ Prisma schema: pridať lat/lng do Activity modelu
|
||||
- ⏸️ Geocoding pri vytváraní aktivity (mesto/adresa → súradnice)
|
||||
- ⏸️ API: GET /api/activities s lat/lng dátami
|
||||
- ⏸️ React komponenta: MapView s Google Maps embed/SDK
|
||||
- ✅ Google Maps API setup (API key)
|
||||
- ✅ Prisma schema: pridať lat/lng do Activity modelu
|
||||
- ✅ Prisma schema: location, locationName
|
||||
- ✅ LocationPicker komponenta s Google Maps Autocomplete
|
||||
- ✅ 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
|
||||
- ⏸️ Info window pri kliknutí na marker (názov, šport, čas)
|
||||
- ⏸️ 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:
|
||||
- ✅ 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 detaile aktivity
|
||||
- ⏸️ 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
|
||||
|
||||
**Status:** 📋 PLANNED
|
||||
**Status:** <EFBFBD> WIP (Work In Progress)
|
||||
|
||||
Ako používateľ
|
||||
chcem filtrovať aktivity podľa skúseností, pohlavia, veku, ceny a ďalších kritérií
|
||||
aby som našiel aktivity, ktoré mi vyhovujú
|
||||
|
||||
**Vývojár:** -
|
||||
**Vývojár:** Jozef Kovalčín
|
||||
|
||||
### Tasky:
|
||||
#### Rozšírenie databázového modelu
|
||||
- ⏸️ Prisma schema: rozšírenie Activity (skillLevel, gender, minAge, maxAge, price)
|
||||
- ⏸️ Prisma schema: UserPreferences model (preferredSports, skillLevel, maxDistance, maxPrice)
|
||||
- ⏸️ Migrácia databázy
|
||||
- ✅ Prisma schema: rozšírenie Activity (skillLevel, gender, minAge, maxAge, price)
|
||||
- ✅ Prisma schema: UserPreferences model (preferredSports, skillLevel, maxDistance, maxPrice)
|
||||
- ✅ Migrácia databázy
|
||||
|
||||
#### Backend
|
||||
- ⏸️ API: GET /api/activities s rozšíreným filtrovaním
|
||||
- ⏸️ Filtrovanie: skillLevel (začiatočník, stredne pokročilý, pokročilý, expert)
|
||||
- ⏸️ Filtrovanie: gender (muži, ženy, zmiešané)
|
||||
- ⏸️ Filtrovanie: vekové rozpätie (minAge-maxAge)
|
||||
- ⏸️ Filtrovanie: cena (od-do)
|
||||
- ✅ API: GET /api/activities s rozšíreným filtrovaním
|
||||
- ✅ Filtrovanie: skillLevel (začiatočník, stredne pokročilý, pokročilý, expert)
|
||||
- ✅ Filtrovanie: gender (muži, ženy, zmiešané)
|
||||
- ✅ Filtrovanie: vekové rozpätie (minAge-maxAge)
|
||||
- ✅ Filtrovanie: cena (od-do)
|
||||
- ⏸️ API: GET/PUT /api/preferences (uloženie používateľských preferencií)
|
||||
|
||||
#### Frontend - Filter panel
|
||||
@ -397,13 +418,20 @@ aby som našiel aktivity, ktoré mi vyhovujú
|
||||
- ⏸️ Mobile-friendly filter (bottom sheet/modal)
|
||||
|
||||
#### Formulár na vytvorenie aktivity
|
||||
- ⏸️ Pridať polia: skillLevel, gender, minAge, maxAge do create formu
|
||||
- ⏸️ Validácia (minAge <= maxAge, price >= 0)
|
||||
- ✅ Pridať polia: skillLevel, gender, minAge, maxAge, price do create formu
|
||||
- ✅ 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:
|
||||
- ⏸️ 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í
|
||||
- ⏸️ Rozšírený create form
|
||||
- ⏸️ 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
|
||||
}
|
||||
|
||||
// Gender preference
|
||||
enum GenderPreference {
|
||||
MALE
|
||||
FEMALE
|
||||
MIXED
|
||||
}
|
||||
|
||||
// Participation status
|
||||
enum ParticipationStatus {
|
||||
CONFIRMED
|
||||
@ -182,13 +189,27 @@ model Activity {
|
||||
currentParticipants Int @default(0)
|
||||
status ActivityStatus @default(OPEN)
|
||||
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
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// 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)
|
||||
participations Participation[]
|
||||
|
||||
@ -196,6 +217,8 @@ model Activity {
|
||||
@@index([date])
|
||||
@@index([status])
|
||||
@@index([venueId])
|
||||
@@index([gender])
|
||||
@@index([skillLevel])
|
||||
}
|
||||
|
||||
// Participation (Účasť na aktivite) model
|
||||
|
||||
@ -24,8 +24,19 @@ const activitySchema = z.object({
|
||||
date: z.string().datetime(),
|
||||
duration: z.number().min(15).max(480),
|
||||
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),
|
||||
}).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
|
||||
@ -35,11 +46,23 @@ export async function GET(request: NextRequest) {
|
||||
const sportType = searchParams.get("sportType");
|
||||
const city = searchParams.get("city");
|
||||
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({
|
||||
where: {
|
||||
...(sportType && { sportType: sportType 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 && {
|
||||
venue: {
|
||||
city: city,
|
||||
|
||||
@ -17,14 +17,22 @@ interface Activity {
|
||||
maxParticipants: number;
|
||||
currentParticipants: number;
|
||||
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;
|
||||
name: string;
|
||||
city: string;
|
||||
address: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
};
|
||||
} | null;
|
||||
organizer: {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -62,6 +70,12 @@ const skillLevelLabels: Record<string, string> = {
|
||||
EXPERT: "Expert",
|
||||
};
|
||||
|
||||
const genderLabels: Record<string, string> = {
|
||||
MALE: "Muži",
|
||||
FEMALE: "Ženy",
|
||||
MIXED: "Zmiešané",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, { label: string; color: string }> = {
|
||||
OPEN: { label: "Otvorená", color: "bg-green-500" },
|
||||
FULL: { label: "Plná", color: "bg-orange-500" },
|
||||
@ -317,14 +331,36 @@ export default function ActivityDetailPage() {
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-[color:var(--fluent-text-secondary)] mb-1">
|
||||
Športovisko
|
||||
Miesto konania
|
||||
</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}
|
||||
{activity.locationName && (
|
||||
<p className="text-lg font-medium text-[color:var(--fluent-text)]">
|
||||
📍 {activity.locationName}
|
||||
</p>
|
||||
)}
|
||||
<p className={`${activity.locationName ? 'text-sm' : 'text-lg'} text-[color:var(--fluent-text-secondary)]`}>
|
||||
{activity.location}
|
||||
</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>
|
||||
@ -337,6 +373,36 @@ export default function ActivityDetailPage() {
|
||||
</span>
|
||||
</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 && (
|
||||
<div>
|
||||
<p className="text-sm text-[color:var(--fluent-text-secondary)] mb-1">
|
||||
@ -352,20 +418,26 @@ export default function ActivityDetailPage() {
|
||||
</Card>
|
||||
|
||||
{/* Map */}
|
||||
{activity.venue.latitude && activity.venue.longitude && (
|
||||
{activity.latitude && activity.longitude && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mapa</CardTitle>
|
||||
</CardHeader>
|
||||
<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
|
||||
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`}
|
||||
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
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
></iframe>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@ -1,18 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState } 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[];
|
||||
}
|
||||
import { LocationPicker } from "@/components/LocationPicker";
|
||||
|
||||
const sportTypes = [
|
||||
{ value: "FOOTBALL", label: "Futbal" },
|
||||
@ -35,11 +28,15 @@ const skillLevels = [
|
||||
{ value: "EXPERT", label: "Expert" },
|
||||
];
|
||||
|
||||
const genderOptions = [
|
||||
{ value: "MIXED", label: "Bez preferencie" },
|
||||
{ value: "MALE", label: "Muži" },
|
||||
{ value: "FEMALE", label: "Ženy" },
|
||||
];
|
||||
|
||||
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({
|
||||
@ -51,35 +48,33 @@ export default function CreateActivityPage() {
|
||||
time: "",
|
||||
duration: 90,
|
||||
maxParticipants: 10,
|
||||
venueId: "",
|
||||
gender: "MIXED" as "MALE" | "FEMALE" | "MIXED",
|
||||
minAge: 18,
|
||||
maxAge: 99,
|
||||
price: 0,
|
||||
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 [location, setLocation] = useState({
|
||||
address: "",
|
||||
name: "",
|
||||
latitude: undefined as number | undefined,
|
||||
longitude: undefined as number | undefined,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
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);
|
||||
setError("");
|
||||
|
||||
@ -98,6 +93,14 @@ export default function CreateActivityPage() {
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
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 (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<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)]">
|
||||
Dĺžka trvania (minúty) *
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="duration"
|
||||
value={formData.duration}
|
||||
onChange={handleChange}
|
||||
required
|
||||
min={15}
|
||||
max={480}
|
||||
/>
|
||||
<div className="relative flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
duration: Math.max(15, prev.duration - 15),
|
||||
}))
|
||||
}
|
||||
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>
|
||||
<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 className="relative flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
maxParticipants: Math.max(2, prev.maxParticipants - 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="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>
|
||||
|
||||
{/* Športovisko */}
|
||||
{/* Miesto konania */}
|
||||
<div className="mb-6">
|
||||
<LocationPicker
|
||||
value={location}
|
||||
onChange={setLocation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pohlavie */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
||||
Športovisko *
|
||||
Pohlavie *
|
||||
</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 className="grid grid-cols-3 gap-3">
|
||||
{genderOptions.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`
|
||||
flex items-center justify-center px-4 py-3 rounded-lg border-2 cursor-pointer transition-all
|
||||
${
|
||||
formData.gender === option.value
|
||||
? 'border-[color:var(--fluent-accent)] bg-[color:var(--fluent-accent)]/10 text-[color:var(--fluent-accent)] font-semibold'
|
||||
: 'border-[color:var(--fluent-border)] bg-[color:var(--fluent-surface-secondary)] text-[color:var(--fluent-text)] hover:border-[color:var(--fluent-border-strong)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="gender"
|
||||
value={option.value}
|
||||
checked={formData.gender === option.value}
|
||||
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>
|
||||
|
||||
{/* Úroveň */}
|
||||
@ -350,7 +556,7 @@ export default function CreateActivityPage() {
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading || venues.length === 0}
|
||||
disabled={loading || !location.address}
|
||||
>
|
||||
{loading ? "Vytváranie..." : "Vytvoriť aktivitu"}
|
||||
</Button>
|
||||
|
||||
@ -16,12 +16,16 @@ interface Activity {
|
||||
maxParticipants: number;
|
||||
currentParticipants: number;
|
||||
status: string;
|
||||
venue: {
|
||||
location: string;
|
||||
locationName: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
venue?: {
|
||||
id: string;
|
||||
name: string;
|
||||
city: string;
|
||||
address: string;
|
||||
};
|
||||
} | null;
|
||||
organizer: {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -105,7 +109,7 @@ function ActivityCard({ activity }: { activity: Activity }) {
|
||||
{/* Location */}
|
||||
<div className="mb-3">
|
||||
<p className="text-sm text-[color:var(--fluent-text)]">
|
||||
📍 {activity.venue.name}, {activity.venue.city}
|
||||
📍 {activity.locationName || activity.location}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -4,9 +4,11 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
|
||||
export default function Home() {
|
||||
const [selectedSport, setSelectedSport] = useState<string | null>(null);
|
||||
const { data: session } = useSession();
|
||||
|
||||
const sports = [
|
||||
{
|
||||
@ -209,32 +211,64 @@ export default function Home() {
|
||||
style={{ boxShadow: "var(--shadow-xl)" }}
|
||||
>
|
||||
<div className="relative z-10">
|
||||
<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>
|
||||
{session ? (
|
||||
<>
|
||||
<h2 className="text-3xl font-bold mb-4 text-[color:var(--fluent-text)]">
|
||||
Objavte nové športové zážitky
|
||||
</h2>
|
||||
<p className="text-[color:var(--fluent-text-secondary)] mb-8 max-w-2xl mx-auto text-lg">
|
||||
Preskúmajte aktivity vo vašom okolí a pripojte sa k športovej komunite.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center flex-wrap">
|
||||
<Link href="/activities">
|
||||
<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"
|
||||
>
|
||||
Preskúmať aktivity
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/activities/create">
|
||||
<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"
|
||||
>
|
||||
Vytvoriť aktivitu
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
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