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:
Jozef Kovalčín 2025-11-06 18:09:06 +01:00
parent 29829adb1d
commit 877100633b
16 changed files with 1334 additions and 158 deletions

View File

@ -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
View 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
View 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

View File

@ -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=""

View File

@ -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
---

View File

@ -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;

View File

@ -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");

View 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"

View File

@ -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

View File

@ -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,

View File

@ -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>

View File

@ -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 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>

View File

@ -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>

View File

@ -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>

View 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>
);
}

View File

@ -0,0 +1,9 @@
/// <reference types="google.maps" />
declare global {
interface Window {
google: typeof google;
}
}
export {};