diff --git a/.env.example b/.env.example index 53a80a8..a76f2a1 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/GOOGLE_MAPS_SETUP.md b/GOOGLE_MAPS_SETUP.md new file mode 100644 index 0000000..6a1e2a8 --- /dev/null +++ b/GOOGLE_MAPS_SETUP.md @@ -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ú. diff --git a/LOCATION_CHANGES.md b/LOCATION_CHANGES.md new file mode 100644 index 0000000..f73906c --- /dev/null +++ b/LOCATION_CHANGES.md @@ -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 diff --git a/README.md b/README.md index 58a1755..6651c21 100644 --- a/README.md +++ b/README.md @@ -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="" diff --git a/USER_STORIES.md b/USER_STORIES.md index 9807a57..be4ac31 100644 --- a/USER_STORIES.md +++ b/USER_STORIES.md @@ -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:** � 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 --- diff --git a/apps/backend/prisma/migrations/20251106155146_add_activity_location/migration.sql b/apps/backend/prisma/migrations/20251106155146_add_activity_location/migration.sql new file mode 100644 index 0000000..e59173a --- /dev/null +++ b/apps/backend/prisma/migrations/20251106155146_add_activity_location/migration.sql @@ -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; diff --git a/apps/backend/prisma/migrations/20251106162407_add_activity_filters/migration.sql b/apps/backend/prisma/migrations/20251106162407_add_activity_filters/migration.sql new file mode 100644 index 0000000..0ad27a3 --- /dev/null +++ b/apps/backend/prisma/migrations/20251106162407_add_activity_filters/migration.sql @@ -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"); diff --git a/apps/backend/prisma/migrations/migration_lock.toml b/apps/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/apps/backend/prisma/migrations/migration_lock.toml @@ -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" diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 302f93c..508361a 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -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 diff --git a/apps/backend/src/app/api/activities/route.ts b/apps/backend/src/app/api/activities/route.ts index a4ca14a..f7a01f2 100644 --- a/apps/backend/src/app/api/activities/route.ts +++ b/apps/backend/src/app/api/activities/route.ts @@ -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, diff --git a/apps/frontend/src/app/activities/[id]/page.tsx b/apps/frontend/src/app/activities/[id]/page.tsx index dc76fdc..e817b45 100644 --- a/apps/frontend/src/app/activities/[id]/page.tsx +++ b/apps/frontend/src/app/activities/[id]/page.tsx @@ -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 = { EXPERT: "Expert", }; +const genderLabels: Record = { + MALE: "Muži", + FEMALE: "Ženy", + MIXED: "Zmiešané", +}; + const statusLabels: Record = { OPEN: { label: "Otvorená", color: "bg-green-500" }, FULL: { label: "Plná", color: "bg-orange-500" }, @@ -317,14 +331,36 @@ export default function ActivityDetailPage() {

- Športovisko + Miesto konania

-

- 📍 {activity.venue.name} -

-

- {activity.venue.address}, {activity.venue.city} + {activity.locationName && ( +

+ 📍 {activity.locationName} +

+ )} +

+ {activity.location}

+ {(activity.locationName || activity.location || (activity.latitude && activity.longitude)) && ( + + + + + + Otvoriť v Mapách + + )}
@@ -337,6 +373,36 @@ export default function ActivityDetailPage() {
+
+
+

+ Pohlavie +

+

+ {genderLabels[activity.gender] || activity.gender} +

+
+
+

+ Vekové rozpätie +

+

+ {activity.minAge} - {activity.maxAge} rokov +

+
+
+ + {activity.price > 0 && ( +
+

+ Cena +

+

+ {activity.price.toFixed(2)} € +

+
+ )} + {activity.description && (

@@ -352,20 +418,26 @@ export default function ActivityDetailPage() { {/* Map */} - {activity.venue.latitude && activity.venue.longitude && ( + {activity.latitude && activity.longitude && ( Mapa -

+
diff --git a/apps/frontend/src/app/activities/create/page.tsx b/apps/frontend/src/app/activities/create/page.tsx index 5f37fad..6a9b2c2 100644 --- a/apps/frontend/src/app/activities/create/page.tsx +++ b/apps/frontend/src/app/activities/create/page.tsx @@ -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([]); - 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 ( -
-
-

- Načítavam... -

-
-
- ); - } - return (
@@ -233,57 +224,272 @@ export default function CreateActivityPage() { - +
+ + + +
- +
+ + + +
- {/* Športovisko */} + {/* Miesto konania */} +
+ +
+ + {/* Pohlavie */}
- {venues.length === 0 ? ( -

- Žiadne športoviská nie sú dostupné. Kontaktujte - administrátora. -

- ) : ( - - )} +
+ {genderOptions.map((option) => ( + + ))} +
+
+ + {/* Vekové rozpätie a cena */} +
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
{/* Úroveň */} @@ -350,7 +556,7 @@ export default function CreateActivityPage() { diff --git a/apps/frontend/src/app/activities/page.tsx b/apps/frontend/src/app/activities/page.tsx index f8434cf..f59776f 100644 --- a/apps/frontend/src/app/activities/page.tsx +++ b/apps/frontend/src/app/activities/page.tsx @@ -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 */}

- 📍 {activity.venue.name}, {activity.venue.city} + 📍 {activity.locationName || activity.location}

diff --git a/apps/frontend/src/app/page.tsx b/apps/frontend/src/app/page.tsx index fa5b800..9296e2d 100644 --- a/apps/frontend/src/app/page.tsx +++ b/apps/frontend/src/app/page.tsx @@ -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(null); + const { data: session } = useSession(); const sports = [ { @@ -209,32 +211,64 @@ export default function Home() { style={{ boxShadow: "var(--shadow-xl)" }} >
-

- Pripravení začať? -

-

- Zaregistrujte sa ešte dnes a staňte sa súčasťou aktívnej športovej - komunity. -

-
- - - - - - -
+ {session ? ( + <> +

+ Objavte nové športové zážitky +

+

+ Preskúmajte aktivity vo vašom okolí a pripojte sa k športovej komunite. +

+
+ + + + + + +
+ + ) : ( + <> +

+ Pripravení začať? +

+

+ Zaregistrujte sa ešte dnes a staňte sa súčasťou aktívnej športovej + komunity. +

+
+ + + + + + +
+ + )}
diff --git a/apps/frontend/src/components/LocationPicker.tsx b/apps/frontend/src/components/LocationPicker.tsx new file mode 100644 index 0000000..52f5d8d --- /dev/null +++ b/apps/frontend/src/components/LocationPicker.tsx @@ -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(null); + const autocompleteRef = useRef(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 ( +
+
+

{error}

+

+ Pre aktiváciu Places API: +

+
    +
  1. Otvorte Google Cloud Console
  2. +
  3. Aktivujte "Places API (New)"
  4. +
  5. Obnovte stránku
  6. +
+

Manuálny vstup:

+
+
+ + + onChange({ ...value, address: e.target.value }) + } + placeholder="napr. Hlavná 1, Bratislava" + required + /> +
+
+ + + onChange({ ...value, name: e.target.value }) + } + placeholder="napr. Park na Kolibe" + /> +
+
+ ); + } + + if (!isLoaded) { + return ( +
+

+ Načítavam Google Maps... +

+
+ ); + } + + return ( +
+
+ +
+

+ Začnite písať a vyberte adresu zo zoznamu návrhov +

+
+ + {value.address && ( +
+
+ + + + +
+ {value.name && ( +

+ {value.name} +

+ )} +

+ {value.address} +

+ {value.latitude && value.longitude && ( +

+ GPS: {value.latitude.toFixed(6)}, {value.longitude.toFixed(6)} +

+ )} +
+
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/types/google-maps.d.ts b/apps/frontend/src/types/google-maps.d.ts new file mode 100644 index 0000000..1185020 --- /dev/null +++ b/apps/frontend/src/types/google-maps.d.ts @@ -0,0 +1,9 @@ +/// + +declare global { + interface Window { + google: typeof google; + } +} + +export {};