feat: recurring activities, my activities page, map view with markers
- Add recurring activities feature (daily, weekly, monthly) - Auto-join with guest count for recurring series - Parent-child relationship for recurring instances - 'Opakovaná' badge and upcoming instances section - Smart delete logic (promote first child to parent) - My Activities page with created/joined tabs - Map view at /venues with activity markers - Custom sport icons and user location marker - InfoWindow with activity details - Navigation renamed 'Športoviská' to 'Mapa aktivít' - Fix participation tracking for joined activities - Database migrations for recurring and guest count fields
This commit is contained in:
parent
2e63914137
commit
a3f926c44f
114
USER_STORIES.md
114
USER_STORIES.md
@ -103,6 +103,12 @@ aby som našiel spoluhráčov
|
|||||||
- ✅ Automatické uloženie GPS súradníc a názvu miesta - 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
|
- ✅ 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
|
- ✅ Custom input tlačidlá s +/- tlačidlami - Jozef Kovalčín
|
||||||
|
- ✅ Pravidelne opakované aktivity (DAILY, WEEKLY, MONTHLY) - Jozef Kovalčín
|
||||||
|
- ✅ Výber dní v týždni pre týždenné opakovanie - Jozef Kovalčín
|
||||||
|
- ✅ Dátum ukončenia opakovania (voliteľný, default 2 mesiace) - Jozef Kovalčín
|
||||||
|
- ✅ Automatické generovanie budúcich inštancií pri vytvorení (max 20 inštancií alebo 2 mesiace) - Jozef Kovalčín
|
||||||
|
- ✅ Auto-join na všetky inštancie s možnosťou zadať počet hostí - Jozef Kovalčín
|
||||||
|
- ✅ Parent-child vzťah medzi opakovanými aktivitami - Jozef Kovalčín
|
||||||
|
|
||||||
### Výsledné funkcie:
|
### Výsledné funkcie:
|
||||||
- ✅ API endpoint funguje
|
- ✅ API endpoint funguje
|
||||||
@ -114,6 +120,13 @@ aby som našiel spoluhráčov
|
|||||||
- ✅ Google Maps autocomplete pre adresu
|
- ✅ Google Maps autocomplete pre adresu
|
||||||
- ✅ Uloženie lokácie, GPS súradníc a názvu miesta
|
- ✅ Uloženie lokácie, GPS súradníc a názvu miesta
|
||||||
- ✅ Všetky US-012 filter polia v create forme
|
- ✅ Všetky US-012 filter polia v create forme
|
||||||
|
- ✅ Pravidelné opakovanie aktivít (denné, týždenné, mesačné)
|
||||||
|
- ✅ UI pre výber dní v týždni
|
||||||
|
- ✅ Automatické vytváranie budúcich aktivít (max 20 inštancií alebo 2 mesiace)
|
||||||
|
- ✅ Auto-join pre organizátora na všetky inštancie s počtom hostí
|
||||||
|
- ✅ Parent-child vzťah s badge "Opakovaná" na kartách aktivít
|
||||||
|
- ✅ Sekcia "Nadchádzajúce termíny" na detail stránke (collapsible)
|
||||||
|
- ✅ Smart delete logic - pri zmazaní parent sa prvá child aktivita stane novým parentom
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -253,37 +266,47 @@ aby som mal dobrý zážitok
|
|||||||
---
|
---
|
||||||
## US-008: OAuth prihlásenie
|
## US-008: OAuth prihlásenie
|
||||||
|
|
||||||
**Status:** 📋 PLANNED
|
**Status:** ✅ HOTOVÉ
|
||||||
|
|
||||||
Ako používateľ
|
Ako používateľ
|
||||||
chcem sa prihlásiť pomocou Google, Facebook alebo iných platforiem
|
chcem sa prihlásiť pomocou Google, Facebook alebo iných platforiem
|
||||||
aby som nemusel vytvárať nové heslo a prihlásenie bolo rýchlejšie
|
aby som nemusel vytvárať nové heslo a prihlásenie bolo rýchlejšie
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
**Vývojár:** - Jozef Kovalčín
|
**Vývojár:** - Jozef Kovalčín
|
||||||
|
=======
|
||||||
|
**Vývojár:** Jozef Kovalčín
|
||||||
|
>>>>>>> ad142ec (feat: recurring activities, my activities page, map view with markers)
|
||||||
|
|
||||||
### Tasky:
|
### Tasky:
|
||||||
- ⏸️ BetterAuth konfigurácia OAuth providers (Google, Facebook)
|
- ✅ BetterAuth konfigurácia OAuth providers (Google, Facebook, Apple)
|
||||||
- ⏸️ Google OAuth setup (Client ID, Secret)
|
- ✅ Google OAuth setup (Client ID, Secret)
|
||||||
- ⏸️ Facebook OAuth setup (App ID, Secret)
|
- ✅ Facebook OAuth setup (App ID, Secret)
|
||||||
- ⏸️ Prisma schema: rozšírenie User modelu (providerId, provider)
|
- ✅ Apple OAuth setup (Client ID, Secret)
|
||||||
- ⏸️ API: OAuth callback handling
|
- ✅ Prisma schema: rozšírenie Account modelu (accessTokenExpiresAt, refreshTokenExpiresAt, scope)
|
||||||
- ⏸️ Tlačidlá "Prihlásiť cez Google/Facebook" na login/register stránke
|
- ✅ Account linking konfigurácia (trustedProviders)
|
||||||
- ⏸️ Mapovanie OAuth dát na User profil (email, meno, avatar)
|
- ✅ API: OAuth callback handling (redirect na frontend URL)
|
||||||
- ⏸️ Handling existujúceho účtu (merge alebo error)
|
- ✅ Error page redirect (na frontend signin page)
|
||||||
- ⏸️ Session management pre OAuth users
|
- ✅ Tlačidlá "Prihlásiť cez Google/Facebook" na login/register stránke
|
||||||
- ⏸️ Responzívne OAuth tlačidlá
|
- ✅ OAuth callback URLs (/api/auth/callback/google, /facebook)
|
||||||
|
- ✅ Mapovanie OAuth dát na User profil (email, meno, avatar)
|
||||||
|
- ✅ Session management pre OAuth users
|
||||||
|
- ✅ Responzívne OAuth tlačidlá
|
||||||
|
- ⏸️ Handling existujúceho účtu (merge alebo error) - funkčné s account linking
|
||||||
|
|
||||||
### Výsledné funkcie:
|
### Výsledné funkcie:
|
||||||
- ⏸️ Google login funguje
|
- ✅ Google login funguje
|
||||||
- ⏸️ Facebook login funguje
|
- ✅ Facebook login funguje (vyžaduje konfiguráciu Facebook Developer App)
|
||||||
- ⏸️ Automatické vytvorenie profilu
|
- ✅ Apple login funguje (vyžaduje konfiguráciu Apple Developer Account)
|
||||||
- ⏸️ Merge s existujúcim emailom (optional)
|
- ✅ Automatické vytvorenie profilu
|
||||||
|
- ✅ Account linking medzi providers (trusted: Google, Facebook, Apple)
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## US-009: Mapa s lokalitami aktivít
|
## US-009: Mapa s lokalitami aktivít
|
||||||
|
|
||||||
**Status:** 🔄 WIP (Work In Progress)
|
**Status:** ✅ HOTOVÉ
|
||||||
|
|
||||||
Ako používateľ
|
Ako používateľ
|
||||||
chcem vidieť polohu aktivít na Google Maps
|
chcem vidieť polohu aktivít na Google Maps
|
||||||
@ -304,8 +327,17 @@ aby som vedel, kde sa aktivita koná a ako ďaleko to mám
|
|||||||
- ✅ Tlačidlo "Otvoriť v Mapách" s deep linking
|
- ✅ Tlačidlo "Otvoriť v Mapách" s deep linking
|
||||||
- ✅ Responzívna mapa (mobile/desktop)
|
- ✅ Responzívna mapa (mobile/desktop)
|
||||||
- ✅ Custom styling pre autocomplete dropdown
|
- ✅ Custom styling pre autocomplete dropdown
|
||||||
- ⏸️ Markery pre jednotlivé aktivity na mape
|
- ✅ Stránka /venues s full-screen mapou (premenovaná na "Mapa aktivít")
|
||||||
- ⏸️ Info window pri kliknutí na marker (názov, šport, čas)
|
- ✅ Google Maps integrácia (useLoadScript hook namiesto LoadScript)
|
||||||
|
- ✅ Markery pre všetky open aktivity na mape
|
||||||
|
- ✅ Custom marker ikony pre každý šport (emoji SVG)
|
||||||
|
- ✅ User location marker (modrý kruh SVG)
|
||||||
|
- ✅ Centrovanie mapy na user location (geolocation API)
|
||||||
|
- ✅ InfoWindow pri kliknutí na marker
|
||||||
|
- ✅ InfoWindow s inline styling (fix pre white background)
|
||||||
|
- ✅ InfoWindow zobrazuje: názov, šport, dátum, čas, účastníci, lokácia, cena
|
||||||
|
- ✅ "Zobraziť detail" button v InfoWindow s linkom na aktivitu
|
||||||
|
- ✅ Legenda s vysvetlením markerov (user location + športy)
|
||||||
- ⏸️ Prepínanie medzi zoznam view a mapa view na /activities
|
- ⏸️ Prepínanie medzi zoznam view a mapa view na /activities
|
||||||
|
|
||||||
### Výsledné funkcie:
|
### Výsledné funkcie:
|
||||||
@ -315,8 +347,12 @@ aby som vedel, kde sa aktivita koná a ako ďaleko to mám
|
|||||||
- ✅ "Otvoriť v Mapách" button (funguje na PC aj mobile)
|
- ✅ "Otvoriť v Mapách" button (funguje na PC aj mobile)
|
||||||
- ✅ Deep linking do Google Maps/Apple Maps
|
- ✅ Deep linking do Google Maps/Apple Maps
|
||||||
- ✅ Custom styled autocomplete dropdown
|
- ✅ Custom styled autocomplete dropdown
|
||||||
- ⏸️ Mapa na zozname aktivít
|
- ✅ Full-screen mapa na /venues s všetkými aktivitami
|
||||||
- ⏸️ Klikateľné markery
|
- ✅ Klikateľné markery s custom ikonami pre každý šport
|
||||||
|
- ✅ InfoWindow s kompletnou informáciou a správnym štýlovaním
|
||||||
|
- ✅ User location detection a zobrazenie
|
||||||
|
- ✅ Legenda pre orientáciu
|
||||||
|
- ⏸️ Toggle medzi listom a mapou
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -885,6 +921,44 @@ aby som sa mohol znova prihlásiť do aplikácie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## US-020: Stránka "Moje aktivity"
|
||||||
|
|
||||||
|
**Status:** ✅ HOTOVÉ
|
||||||
|
|
||||||
|
Ako používateľ
|
||||||
|
chcem mať prehľad všetkých mojich aktivít (vytvorených aj prihlásených) na jednom mieste
|
||||||
|
aby som vedel, na čo som sa prihlásil a čo som vytvoril
|
||||||
|
|
||||||
|
**Vývojár:** Jozef Kovalčín
|
||||||
|
|
||||||
|
### Tasky:
|
||||||
|
- ✅ API: GET /api/activities/my (vracia created a joined aktivity)
|
||||||
|
- ✅ Backend logika: created = organizerId === userId
|
||||||
|
- ✅ Backend logika: joined = participation existuje pre userId (vrátane vlastných aktivít)
|
||||||
|
- ✅ Stránka /my-activities s tab navigáciou
|
||||||
|
- ✅ Tab "Vytvorené" (created activities)
|
||||||
|
- ✅ Tab "Prihlásené" (joined activities vrátane vlastných)
|
||||||
|
- ✅ Štatistiky: Total Created, Total Joined, Upcoming Created, Upcoming Joined
|
||||||
|
- ✅ Activity cards s badge "Organizátor" pre vytvorené
|
||||||
|
- ✅ Badge "Opakovaná" pre recurring aktivity
|
||||||
|
- ✅ Smart back navigation (sessionStorage tracking)
|
||||||
|
- ✅ Delete button pre organizátorov na detail stránke
|
||||||
|
- ✅ Redirect na source page po zmazaní (activities vs my-activities)
|
||||||
|
- ✅ Empty states pre obe záložky
|
||||||
|
- ✅ CTA buttons: "Vytvoriť aktivitu" / "Prehliadať aktivity"
|
||||||
|
- ✅ Responzívny dizajn
|
||||||
|
|
||||||
|
### Výsledné funkcie:
|
||||||
|
- ✅ Backend API vracia vytvorené aj prihlásené aktivity
|
||||||
|
- ✅ Tab navigácia medzi vytvorenými a prihlásenými
|
||||||
|
- ✅ Štatistiky zobrazujú správne počty
|
||||||
|
- ✅ Activity cards s visual badges (Organizátor, Opakovaná)
|
||||||
|
- ✅ Delete funkcia pre organizátorov s smart navigation
|
||||||
|
- ✅ Smart back button tracking (vracia na správnu stránku)
|
||||||
|
- ✅ Empty states s CTA akciami
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Legenda
|
## Legenda
|
||||||
|
|
||||||
- ✅ Hotové (Completed)
|
- ✅ Hotové (Completed)
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
experimental: {
|
experimental: {
|
||||||
webpackBuildWorker: true,
|
webpackBuildWorker: false,
|
||||||
},
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
"node": ">=20.16.0"
|
"node": ">=20.16.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3001",
|
"dev": "NODE_OPTIONS='--no-warnings' next dev -p 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3001",
|
"start": "next start -p 3001",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `expiresAt` on the `Account` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Account" DROP COLUMN "expiresAt",
|
||||||
|
ADD COLUMN "accessTokenExpiresAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "refreshTokenExpiresAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "scope" TEXT;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Participation" ADD COLUMN "guestCount" INTEGER NOT NULL DEFAULT 0;
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RecurrenceFrequency" AS ENUM ('NONE', 'DAILY', 'WEEKLY', 'MONTHLY');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Activity" ADD COLUMN "isRecurring" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "parentActivityId" TEXT,
|
||||||
|
ADD COLUMN "recurrenceDays" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
|
||||||
|
ADD COLUMN "recurrenceEndDate" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "recurrenceFrequency" "RecurrenceFrequency" NOT NULL DEFAULT 'NONE';
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Activity_parentActivityId_idx" ON "Activity"("parentActivityId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Activity_isRecurring_idx" ON "Activity"("isRecurring");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_parentActivityId_fkey" FOREIGN KEY ("parentActivityId") REFERENCES "Activity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@ -70,7 +70,9 @@ model Account {
|
|||||||
accessToken String? @db.Text
|
accessToken String? @db.Text
|
||||||
refreshToken String? @db.Text
|
refreshToken String? @db.Text
|
||||||
idToken String? @db.Text
|
idToken String? @db.Text
|
||||||
expiresAt DateTime?
|
accessTokenExpiresAt DateTime?
|
||||||
|
refreshTokenExpiresAt DateTime?
|
||||||
|
scope String?
|
||||||
password String?
|
password String?
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@ -176,6 +178,14 @@ model Venue {
|
|||||||
@@index([sportTypes])
|
@@index([sportTypes])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recurrence frequency enumeration
|
||||||
|
enum RecurrenceFrequency {
|
||||||
|
NONE
|
||||||
|
DAILY
|
||||||
|
WEEKLY
|
||||||
|
MONTHLY
|
||||||
|
}
|
||||||
|
|
||||||
// Activity (Športová aktivita) model
|
// Activity (Športová aktivita) model
|
||||||
model Activity {
|
model Activity {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@ -202,6 +212,13 @@ model Activity {
|
|||||||
latitude Float?
|
latitude Float?
|
||||||
longitude Float?
|
longitude Float?
|
||||||
|
|
||||||
|
// Recurrence fields
|
||||||
|
isRecurring Boolean @default(false)
|
||||||
|
recurrenceFrequency RecurrenceFrequency @default(NONE)
|
||||||
|
recurrenceDays Int[] @default([]) // Days of week: 0=Sunday, 1=Monday, etc.
|
||||||
|
recurrenceEndDate DateTime? // When to stop creating recurring activities
|
||||||
|
parentActivityId String? // Reference to the original recurring activity
|
||||||
|
|
||||||
// Venue is now optional for backward compatibility
|
// Venue is now optional for backward compatibility
|
||||||
venueId String?
|
venueId String?
|
||||||
organizerId String
|
organizerId String
|
||||||
@ -212,6 +229,8 @@ model Activity {
|
|||||||
venue Venue? @relation(fields: [venueId], references: [id], onDelete: SetNull)
|
venue Venue? @relation(fields: [venueId], references: [id], onDelete: SetNull)
|
||||||
organizer User @relation(fields: [organizerId], references: [id], onDelete: Cascade)
|
organizer User @relation(fields: [organizerId], references: [id], onDelete: Cascade)
|
||||||
participations Participation[]
|
participations Participation[]
|
||||||
|
parentActivity Activity? @relation("RecurringActivities", fields: [parentActivityId], references: [id], onDelete: SetNull)
|
||||||
|
childActivities Activity[] @relation("RecurringActivities")
|
||||||
|
|
||||||
@@index([sportType])
|
@@index([sportType])
|
||||||
@@index([date])
|
@@index([date])
|
||||||
@ -219,6 +238,8 @@ model Activity {
|
|||||||
@@index([venueId])
|
@@index([venueId])
|
||||||
@@index([gender])
|
@@index([gender])
|
||||||
@@index([skillLevel])
|
@@index([skillLevel])
|
||||||
|
@@index([parentActivityId])
|
||||||
|
@@index([isRecurring])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Participation (Účasť na aktivite) model
|
// Participation (Účasť na aktivite) model
|
||||||
@ -227,6 +248,7 @@ model Participation {
|
|||||||
userId String
|
userId String
|
||||||
activityId String
|
activityId String
|
||||||
status ParticipationStatus @default(CONFIRMED)
|
status ParticipationStatus @default(CONFIRMED)
|
||||||
|
guestCount Int @default(0) // Number of additional guests brought by this participant
|
||||||
joinedAt DateTime @default(now())
|
joinedAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
|
|||||||
@ -18,6 +18,23 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse request body for guest count
|
||||||
|
let guestCount = 0;
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
guestCount = body.guestCount || 0;
|
||||||
|
} catch {
|
||||||
|
// If no body, default to 0
|
||||||
|
guestCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guestCount < 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Počet hostí nemôže byť záporný" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const activity = await prisma.activity.findUnique({
|
const activity = await prisma.activity.findUnique({
|
||||||
where: { id: id },
|
where: { id: id },
|
||||||
include: {
|
include: {
|
||||||
@ -39,13 +56,6 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activity.currentParticipants >= activity.maxParticipants) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Aktivita je už naplnená" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is already participating
|
// Check if user is already participating
|
||||||
const existingParticipation = await prisma.participation.findUnique({
|
const existingParticipation = await prisma.participation.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@ -57,36 +67,79 @@ export async function POST(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existingParticipation) {
|
if (existingParticipation) {
|
||||||
|
// If already participating, update guest count
|
||||||
|
const totalParticipants = 1 + guestCount; // user + guests
|
||||||
|
const currentTotalWithoutThisUser = activity.currentParticipants - (1 + existingParticipation.guestCount);
|
||||||
|
const newTotal = currentTotalWithoutThisUser + totalParticipants;
|
||||||
|
|
||||||
|
const availableSpots = activity.maxParticipants - currentTotalWithoutThisUser;
|
||||||
|
if (totalParticipants > availableSpots) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `K dispozícii je len ${availableSpots} voľných miest` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update participation with new guest count
|
||||||
|
await prisma.participation.update({
|
||||||
|
where: {
|
||||||
|
userId_activityId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
activityId: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
guestCount: guestCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update activity participant count
|
||||||
|
await prisma.activity.update({
|
||||||
|
where: { id: id },
|
||||||
|
data: {
|
||||||
|
currentParticipants: newTotal,
|
||||||
|
status: newTotal >= activity.maxParticipants ? "FULL" : "OPEN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Počet hostí aktualizovaný",
|
||||||
|
guestCount: guestCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check available spots (1 for user + guestCount)
|
||||||
|
const totalNeeded = 1 + guestCount;
|
||||||
|
const availableSpots = activity.maxParticipants - activity.currentParticipants;
|
||||||
|
if (totalNeeded > availableSpots) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Už ste prihlásený na túto aktivitu" },
|
{ error: `K dispozícii je len ${availableSpots} voľných miest` },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create participation
|
// Create new participation
|
||||||
const participation = await prisma.participation.create({
|
await prisma.participation.create({
|
||||||
data: {
|
data: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
activityId: id,
|
activityId: id,
|
||||||
status: "CONFIRMED",
|
status: "CONFIRMED",
|
||||||
|
guestCount: guestCount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update activity participant count
|
// Update activity participant count (user + guests)
|
||||||
|
const newParticipantCount = activity.currentParticipants + totalNeeded;
|
||||||
|
|
||||||
const updatedActivity = await prisma.activity.update({
|
const updatedActivity = await prisma.activity.update({
|
||||||
where: { id: id },
|
where: { id: id },
|
||||||
data: {
|
data: {
|
||||||
currentParticipants: {
|
currentParticipants: newParticipantCount,
|
||||||
increment: 1,
|
status: newParticipantCount >= activity.maxParticipants ? "FULL" : "OPEN",
|
||||||
},
|
|
||||||
status:
|
|
||||||
activity.currentParticipants + 1 >= activity.maxParticipants
|
|
||||||
? "FULL"
|
|
||||||
: "OPEN",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ participation, activity: updatedActivity });
|
return NextResponse.json({ activity: updatedActivity });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error joining activity:", error);
|
console.error("Error joining activity:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -123,13 +176,26 @@ export async function DELETE(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activity.organizerId === session.user.id) {
|
// Check participation exists
|
||||||
|
const participation = await prisma.participation.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_activityId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
activityId: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!participation) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Organizátor nemôže opustiť vlastnú aktivitu" },
|
{ error: "Nie ste prihlásený na túto aktivitu" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate total participants to remove (user + guests)
|
||||||
|
const totalToRemove = 1 + participation.guestCount;
|
||||||
|
|
||||||
// Delete participation
|
// Delete participation
|
||||||
await prisma.participation.delete({
|
await prisma.participation.delete({
|
||||||
where: {
|
where: {
|
||||||
@ -145,7 +211,7 @@ export async function DELETE(
|
|||||||
where: { id: id },
|
where: { id: id },
|
||||||
data: {
|
data: {
|
||||||
currentParticipants: {
|
currentParticipants: {
|
||||||
decrement: 1,
|
decrement: totalToRemove,
|
||||||
},
|
},
|
||||||
status: "OPEN",
|
status: "OPEN",
|
||||||
},
|
},
|
||||||
@ -160,3 +226,4 @@ export async function DELETE(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -148,6 +148,61 @@ export async function DELETE(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If this is a parent activity, handle child activities
|
||||||
|
if (activity.isRecurring && activity.recurrenceFrequency !== "NONE") {
|
||||||
|
const childActivities = await prisma.activity.findMany({
|
||||||
|
where: { parentActivityId: id },
|
||||||
|
orderBy: { date: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (childActivities.length === 1) {
|
||||||
|
// Only 1 child remains - make it standalone
|
||||||
|
await prisma.activity.update({
|
||||||
|
where: { id: childActivities[0].id },
|
||||||
|
data: { parentActivityId: null },
|
||||||
|
});
|
||||||
|
} else if (childActivities.length > 1) {
|
||||||
|
// Multiple children remain - promote first child to new parent
|
||||||
|
const newParent = childActivities[0];
|
||||||
|
await prisma.activity.update({
|
||||||
|
where: { id: newParent.id },
|
||||||
|
data: {
|
||||||
|
isRecurring: true,
|
||||||
|
recurrenceFrequency: activity.recurrenceFrequency,
|
||||||
|
recurrenceDays: activity.recurrenceDays,
|
||||||
|
recurrenceEndDate: activity.recurrenceEndDate,
|
||||||
|
parentActivityId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update remaining children to point to new parent
|
||||||
|
for (let i = 1; i < childActivities.length; i++) {
|
||||||
|
await prisma.activity.update({
|
||||||
|
where: { id: childActivities[i].id },
|
||||||
|
data: { parentActivityId: newParent.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is a child activity, check remaining siblings
|
||||||
|
if (activity.parentActivityId) {
|
||||||
|
const siblings = await prisma.activity.findMany({
|
||||||
|
where: {
|
||||||
|
parentActivityId: activity.parentActivityId,
|
||||||
|
id: { not: id }, // Exclude current activity being deleted
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If only one sibling remains after deletion, remove its parentActivityId
|
||||||
|
if (siblings.length === 1) {
|
||||||
|
await prisma.activity.update({
|
||||||
|
where: { id: siblings[0].id },
|
||||||
|
data: { parentActivityId: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.activity.delete({
|
await prisma.activity.delete({
|
||||||
where: { id: id },
|
where: { id: id },
|
||||||
});
|
});
|
||||||
|
|||||||
87
apps/backend/src/app/api/activities/[id]/upcoming/route.ts
Normal file
87
apps/backend/src/app/api/activities/[id]/upcoming/route.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// GET /api/activities/[id]/upcoming - Get upcoming recurring instances
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// First check if this is a parent recurring activity
|
||||||
|
const activity = await prisma.activity.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
isRecurring: true,
|
||||||
|
recurrenceFrequency: true,
|
||||||
|
parentActivityId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activity) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Aktivita nenájdená" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the parent ID (either this activity or its parent)
|
||||||
|
const parentId = activity.parentActivityId || id;
|
||||||
|
const isRecurring = activity.parentActivityId
|
||||||
|
? true // If it has a parent, it's a child of recurring activity
|
||||||
|
: activity.isRecurring && activity.recurrenceFrequency !== "NONE";
|
||||||
|
|
||||||
|
// If it's a recurring activity (or child of one), find all instances
|
||||||
|
if (isRecurring) {
|
||||||
|
const upcomingInstances = await prisma.activity.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ id: parentId }, // Include the parent
|
||||||
|
{ parentActivityId: parentId }, // Include all children
|
||||||
|
],
|
||||||
|
date: {
|
||||||
|
gte: new Date(), // Only future dates
|
||||||
|
},
|
||||||
|
status: "OPEN", // Only open activities
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
organizer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
participations: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
date: "asc",
|
||||||
|
},
|
||||||
|
take: 10, // Limit to next 10 instances
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(upcomingInstances);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not recurring, return empty array
|
||||||
|
return NextResponse.json([]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching upcoming instances:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Chyba pri načítaní nadchádzajúcich termínov" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -47,7 +47,7 @@ export async function GET(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get activities user joined
|
// Get activities user joined (including those they organized if they also joined)
|
||||||
const joinedActivities = await prisma.activity.findMany({
|
const joinedActivities = await prisma.activity.findMany({
|
||||||
where: {
|
where: {
|
||||||
participations: {
|
participations: {
|
||||||
@ -55,9 +55,6 @@ export async function GET(req: NextRequest) {
|
|||||||
userId: userId,
|
userId: userId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
organizerId: {
|
|
||||||
not: userId, // Exclude activities organized by user (already in createdActivities)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
venue: true,
|
venue: true,
|
||||||
|
|||||||
@ -34,9 +34,24 @@ const activitySchema = z.object({
|
|||||||
maxAge: z.number().min(6).max(99).default(99),
|
maxAge: z.number().min(6).max(99).default(99),
|
||||||
price: z.number().min(0).default(0),
|
price: z.number().min(0).default(0),
|
||||||
isPublic: z.boolean().default(true),
|
isPublic: z.boolean().default(true),
|
||||||
|
// Recurrence fields
|
||||||
|
isRecurring: z.boolean().default(false),
|
||||||
|
recurrenceFrequency: z.enum(["NONE", "DAILY", "WEEKLY", "MONTHLY"]).default("NONE"),
|
||||||
|
recurrenceDays: z.array(z.number().min(0).max(6)).default([]), // 0=Sunday, 1=Monday, etc.
|
||||||
|
recurrenceEndDate: z.string().datetime().optional(),
|
||||||
|
autoJoinAll: z.boolean().default(false),
|
||||||
|
autoJoinGuestCount: z.number().min(0).max(10).default(0),
|
||||||
}).refine((data) => data.minAge <= data.maxAge, {
|
}).refine((data) => data.minAge <= data.maxAge, {
|
||||||
message: "Minimálny vek musí byť menší alebo rovný maximálnemu veku",
|
message: "Minimálny vek musí byť menší alebo rovný maximálnemu veku",
|
||||||
path: ["minAge"],
|
path: ["minAge"],
|
||||||
|
}).refine((data) => {
|
||||||
|
if (data.isRecurring && data.recurrenceFrequency === "WEEKLY" && data.recurrenceDays.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, {
|
||||||
|
message: "Pre týždenné opakovanie musíte vybrať aspoň jeden deň v týždni",
|
||||||
|
path: ["recurrenceDays"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/activities - Get all activities
|
// GET /api/activities - Get all activities
|
||||||
@ -63,6 +78,7 @@ export async function GET(request: NextRequest) {
|
|||||||
...(maxPrice && { price: { lte: parseFloat(maxPrice) } }),
|
...(maxPrice && { price: { lte: parseFloat(maxPrice) } }),
|
||||||
...(minAge && { maxAge: { gte: parseInt(minAge) } }), // User age >= activity minAge
|
...(minAge && { maxAge: { gte: parseInt(minAge) } }), // User age >= activity minAge
|
||||||
...(maxAge && { minAge: { lte: parseInt(maxAge) } }), // User age <= activity maxAge
|
...(maxAge && { minAge: { lte: parseInt(maxAge) } }), // User age <= activity maxAge
|
||||||
|
parentActivityId: null, // Only show parent activities, not recurring instances
|
||||||
...(city && {
|
...(city && {
|
||||||
venue: {
|
venue: {
|
||||||
city: city,
|
city: city,
|
||||||
@ -123,13 +139,22 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const validatedData = activitySchema.parse(body);
|
const validatedData = activitySchema.parse(body);
|
||||||
|
|
||||||
|
// Extract auto-join settings before creating activity
|
||||||
|
const { autoJoinAll, autoJoinGuestCount, ...activityFields } = validatedData;
|
||||||
|
|
||||||
|
// Create base activity data
|
||||||
|
const activityData: any = {
|
||||||
|
...activityFields,
|
||||||
|
date: new Date(validatedData.date),
|
||||||
|
organizerId: session.user.id,
|
||||||
|
currentParticipants: 1 + (autoJoinGuestCount || 0), // 1 for organizer + guests
|
||||||
|
recurrenceEndDate: validatedData.recurrenceEndDate
|
||||||
|
? new Date(validatedData.recurrenceEndDate)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
const activity = await prisma.activity.create({
|
const activity = await prisma.activity.create({
|
||||||
data: {
|
data: activityData,
|
||||||
...validatedData,
|
|
||||||
date: new Date(validatedData.date),
|
|
||||||
organizerId: session.user.id,
|
|
||||||
currentParticipants: 1,
|
|
||||||
},
|
|
||||||
include: {
|
include: {
|
||||||
venue: true,
|
venue: true,
|
||||||
organizer: {
|
organizer: {
|
||||||
@ -148,9 +173,15 @@ export async function POST(request: NextRequest) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
activityId: activity.id,
|
activityId: activity.id,
|
||||||
status: "CONFIRMED",
|
status: "CONFIRMED",
|
||||||
|
guestCount: validatedData.autoJoinAll ? validatedData.autoJoinGuestCount : 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If recurring, create future instances
|
||||||
|
if (activity.isRecurring && activity.recurrenceFrequency !== "NONE") {
|
||||||
|
await createRecurringActivities(activity, session.user.id, validatedData.autoJoinAll, validatedData.autoJoinGuestCount);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(activity, { status: 201 });
|
return NextResponse.json(activity, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
@ -167,3 +198,86 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to create recurring activities
|
||||||
|
async function createRecurringActivities(parentActivity: any, userId: string, autoJoinAll: boolean, guestCount: number) {
|
||||||
|
const maxInstances = 20; // Maximum 20 instances
|
||||||
|
let instancesCreated = 0;
|
||||||
|
const endDate = parentActivity.recurrenceEndDate || new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); // 2 months default
|
||||||
|
|
||||||
|
let currentDate = new Date(parentActivity.date);
|
||||||
|
|
||||||
|
while (instancesCreated < maxInstances && currentDate < endDate) {
|
||||||
|
// Calculate next occurrence based on frequency
|
||||||
|
if (parentActivity.recurrenceFrequency === "DAILY") {
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
} else if (parentActivity.recurrenceFrequency === "WEEKLY") {
|
||||||
|
// Find next matching day of week
|
||||||
|
let daysToAdd = 1;
|
||||||
|
let nextDate = new Date(currentDate);
|
||||||
|
nextDate.setDate(nextDate.getDate() + daysToAdd);
|
||||||
|
|
||||||
|
while (!parentActivity.recurrenceDays.includes(nextDate.getDay()) && daysToAdd < 8) {
|
||||||
|
daysToAdd++;
|
||||||
|
nextDate = new Date(currentDate);
|
||||||
|
nextDate.setDate(nextDate.getDate() + daysToAdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDate = nextDate;
|
||||||
|
} else if (parentActivity.recurrenceFrequency === "MONTHLY") {
|
||||||
|
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop if we've passed the end date
|
||||||
|
if (currentDate >= endDate) break;
|
||||||
|
|
||||||
|
// Create child activity
|
||||||
|
try {
|
||||||
|
const childActivity = await prisma.activity.create({
|
||||||
|
data: {
|
||||||
|
title: parentActivity.title,
|
||||||
|
description: parentActivity.description,
|
||||||
|
sportType: parentActivity.sportType,
|
||||||
|
skillLevel: parentActivity.skillLevel,
|
||||||
|
date: new Date(currentDate),
|
||||||
|
duration: parentActivity.duration,
|
||||||
|
maxParticipants: parentActivity.maxParticipants,
|
||||||
|
gender: parentActivity.gender,
|
||||||
|
minAge: parentActivity.minAge,
|
||||||
|
maxAge: parentActivity.maxAge,
|
||||||
|
price: parentActivity.price,
|
||||||
|
location: parentActivity.location,
|
||||||
|
locationName: parentActivity.locationName,
|
||||||
|
latitude: parentActivity.latitude,
|
||||||
|
longitude: parentActivity.longitude,
|
||||||
|
venueId: parentActivity.venueId,
|
||||||
|
organizerId: parentActivity.organizerId,
|
||||||
|
isPublic: parentActivity.isPublic,
|
||||||
|
isRecurring: false, // Child activities are not recurring
|
||||||
|
recurrenceFrequency: "NONE",
|
||||||
|
parentActivityId: parentActivity.id,
|
||||||
|
currentParticipants: autoJoinAll ? 1 + guestCount : 0, // 1 for organizer + guests
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-join organizer if requested
|
||||||
|
if (autoJoinAll) {
|
||||||
|
await prisma.participation.create({
|
||||||
|
data: {
|
||||||
|
userId: userId,
|
||||||
|
activityId: childActivity.id,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
guestCount: guestCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
instancesCreated++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating recurring instance:", error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Created ${instancesCreated} recurring activity instances`);
|
||||||
|
}
|
||||||
|
|||||||
@ -16,12 +16,24 @@ export const auth = betterAuth({
|
|||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
||||||
enabled: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET,
|
enabled: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET,
|
||||||
},
|
},
|
||||||
|
facebook: {
|
||||||
|
clientId: process.env.FACEBOOK_CLIENT_ID || "",
|
||||||
|
clientSecret: process.env.FACEBOOK_CLIENT_SECRET || "",
|
||||||
|
enabled: !!process.env.FACEBOOK_CLIENT_ID && !!process.env.FACEBOOK_CLIENT_SECRET,
|
||||||
|
},
|
||||||
apple: {
|
apple: {
|
||||||
clientId: process.env.APPLE_CLIENT_ID || "",
|
clientId: process.env.APPLE_CLIENT_ID || "",
|
||||||
clientSecret: process.env.APPLE_CLIENT_SECRET || "",
|
clientSecret: process.env.APPLE_CLIENT_SECRET || "",
|
||||||
enabled: !!process.env.APPLE_CLIENT_ID && !!process.env.APPLE_CLIENT_SECRET,
|
enabled: !!process.env.APPLE_CLIENT_ID && !!process.env.APPLE_CLIENT_SECRET,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Automatic account linking by email
|
||||||
|
account: {
|
||||||
|
accountLinking: {
|
||||||
|
enabled: true,
|
||||||
|
trustedProviders: ["google", "facebook", "apple"],
|
||||||
|
},
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
expiresIn: 60 * 60 * 24 * 30, // 30 days
|
expiresIn: 60 * 60 * 24 * 30, // 30 days
|
||||||
updateAge: 60 * 60 * 24, // 1 day
|
updateAge: 60 * 60 * 24, // 1 day
|
||||||
@ -30,6 +42,14 @@ export const auth = betterAuth({
|
|||||||
generateId: () => crypto.randomUUID(),
|
generateId: () => crypto.randomUUID(),
|
||||||
},
|
},
|
||||||
trustedOrigins: ["http://localhost:3000"],
|
trustedOrigins: ["http://localhost:3000"],
|
||||||
|
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3001",
|
||||||
|
basePath: "/api/auth",
|
||||||
|
// Redirect to frontend after errors
|
||||||
|
pages: {
|
||||||
|
errorPage: process.env.NEXT_PUBLIC_FRONTEND_URL
|
||||||
|
? `${process.env.NEXT_PUBLIC_FRONTEND_URL}/auth/signin`
|
||||||
|
: "http://localhost:3000/auth/signin",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Session = typeof auth.$Infer.Session.session;
|
export type Session = typeof auth.$Infer.Session.session;
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-google-maps/api": "latest",
|
||||||
"@tailwindcss/postcss": "latest",
|
"@tailwindcss/postcss": "latest",
|
||||||
"@tailwindcss/typography": "latest",
|
"@tailwindcss/typography": "latest",
|
||||||
"better-auth": "latest",
|
"better-auth": "latest",
|
||||||
|
|||||||
@ -25,6 +25,11 @@ interface Activity {
|
|||||||
minAge: number;
|
minAge: number;
|
||||||
maxAge: number;
|
maxAge: number;
|
||||||
price: number;
|
price: number;
|
||||||
|
isRecurring: boolean;
|
||||||
|
recurrenceFrequency: string;
|
||||||
|
recurrenceDays: number[];
|
||||||
|
recurrenceEndDate: string | null;
|
||||||
|
parentActivityId: string | null;
|
||||||
venue?: {
|
venue?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -41,6 +46,7 @@ interface Activity {
|
|||||||
};
|
};
|
||||||
participations: {
|
participations: {
|
||||||
id: string;
|
id: string;
|
||||||
|
guestCount: number;
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -91,13 +97,50 @@ export default function ActivityDetailPage() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [joining, setJoining] = useState(false);
|
const [joining, setJoining] = useState(false);
|
||||||
const [leaving, setLeaving] = useState(false);
|
const [leaving, setLeaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||||
|
const [guestCount, setGuestCount] = useState(1);
|
||||||
|
const [addingGuests, setAddingGuests] = useState(false);
|
||||||
|
const [upcomingInstances, setUpcomingInstances] = useState<Activity[]>([]);
|
||||||
|
const [loadingUpcoming, setLoadingUpcoming] = useState(false);
|
||||||
|
const [showUpcoming, setShowUpcoming] = useState(false);
|
||||||
|
const [returnUrl, setReturnUrl] = useState("/activities");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if we came from my-activities page via referrer or sessionStorage
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const savedSource = sessionStorage.getItem("activityListSource");
|
||||||
|
if (savedSource === "/my-activities") {
|
||||||
|
setReturnUrl("/my-activities");
|
||||||
|
} else if (document.referrer && document.referrer.includes("/my-activities")) {
|
||||||
|
setReturnUrl("/my-activities");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchActivity();
|
fetchActivity();
|
||||||
fetchCurrentUser();
|
fetchCurrentUser();
|
||||||
}, [params.id]);
|
}, [params.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Update guest count when activity changes
|
||||||
|
if (activity && currentUserId) {
|
||||||
|
const participation = activity.participations.find(p => p.user.id === currentUserId);
|
||||||
|
if (participation) {
|
||||||
|
setGuestCount(participation.guestCount || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch upcoming instances if recurring (including child activities)
|
||||||
|
if (activity) {
|
||||||
|
const isRecurringOrChild = (activity.isRecurring && activity.recurrenceFrequency !== "NONE") || activity.parentActivityId;
|
||||||
|
if (isRecurringOrChild) {
|
||||||
|
fetchUpcomingInstances();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activity, currentUserId]);
|
||||||
|
|
||||||
const fetchCurrentUser = async () => {
|
const fetchCurrentUser = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
@ -132,6 +175,23 @@ export default function ActivityDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchUpcomingInstances = async () => {
|
||||||
|
setLoadingUpcoming(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/api/activities/${params.id}/upcoming`
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setUpcomingInstances(data);
|
||||||
|
// Don't auto-expand, keep collapsed by default
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching upcoming instances:", err);
|
||||||
|
} finally {
|
||||||
|
setLoadingUpcoming(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const handleJoin = async () => {
|
const handleJoin = async () => {
|
||||||
if (!activity) return;
|
if (!activity) return;
|
||||||
setJoining(true);
|
setJoining(true);
|
||||||
@ -140,7 +200,11 @@ export default function ActivityDetailPage() {
|
|||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/activities/${activity.id}/join`,
|
`${process.env.NEXT_PUBLIC_API_URL}/api/activities/${activity.id}/join`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ guestCount: 0 }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -160,6 +224,12 @@ export default function ActivityDetailPage() {
|
|||||||
|
|
||||||
const handleLeave = async () => {
|
const handleLeave = async () => {
|
||||||
if (!activity) return;
|
if (!activity) return;
|
||||||
|
|
||||||
|
const confirmLeave = window.confirm(
|
||||||
|
"Naozaj sa chcete odhlásiť z tejto aktivity?"
|
||||||
|
);
|
||||||
|
if (!confirmLeave) return;
|
||||||
|
|
||||||
setLeaving(true);
|
setLeaving(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
@ -184,6 +254,81 @@ export default function ActivityDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddGuests = async () => {
|
||||||
|
if (!activity) return;
|
||||||
|
|
||||||
|
const currentParticipation = activity.participations.find(p => p.user.id === currentUserId);
|
||||||
|
const currentGuestCount = currentParticipation?.guestCount || 0;
|
||||||
|
const totalNeeded = 1 + guestCount; // user + guests
|
||||||
|
const currentTotal = 1 + currentGuestCount; // current user + current guests
|
||||||
|
const availableSpots = activity.maxParticipants - activity.currentParticipants + currentTotal;
|
||||||
|
|
||||||
|
if (totalNeeded > availableSpots) {
|
||||||
|
alert(`K dispozícii je len ${availableSpots} voľných miest.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAddingGuests(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/api/activities/${activity.id}/join`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ guestCount }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "Chyba pri aktualizácii počtu hostí");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh activity data
|
||||||
|
await fetchActivity();
|
||||||
|
alert(`Počet hostí aktualizovaný na ${guestCount}!`);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setAddingGuests(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!activity) return;
|
||||||
|
|
||||||
|
const confirmDelete = window.confirm(
|
||||||
|
"Naozaj chcete zmazať túto aktivitu? Táto akcia je nevratná."
|
||||||
|
);
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/api/activities/${activity.id}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "Chyba pri mazaní aktivity");
|
||||||
|
}
|
||||||
|
|
||||||
|
alert("Aktivita bola úspešne zmazaná");
|
||||||
|
router.push(returnUrl);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
@ -206,7 +351,7 @@ export default function ActivityDetailPage() {
|
|||||||
<h3 className="text-xl font-semibold text-[color:var(--fluent-text)] mb-2">
|
<h3 className="text-xl font-semibold text-[color:var(--fluent-text)] mb-2">
|
||||||
{error || "Aktivita nenájdená"}
|
{error || "Aktivita nenájdená"}
|
||||||
</h3>
|
</h3>
|
||||||
<Link href="/activities">
|
<Link href={returnUrl}>
|
||||||
<Button variant="primary" className="mt-4">
|
<Button variant="primary" className="mt-4">
|
||||||
Späť na zoznam
|
Späť na zoznam
|
||||||
</Button>
|
</Button>
|
||||||
@ -235,7 +380,6 @@ export default function ActivityDetailPage() {
|
|||||||
const isOrganizer = activity.organizer.id === currentUserId;
|
const isOrganizer = activity.organizer.id === currentUserId;
|
||||||
const canJoin =
|
const canJoin =
|
||||||
!isParticipating &&
|
!isParticipating &&
|
||||||
!isOrganizer &&
|
|
||||||
activity.status === "OPEN" &&
|
activity.status === "OPEN" &&
|
||||||
activity.currentParticipants < activity.maxParticipants;
|
activity.currentParticipants < activity.maxParticipants;
|
||||||
|
|
||||||
@ -247,7 +391,7 @@ export default function ActivityDetailPage() {
|
|||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
<Link href="/activities">
|
<Link href={returnUrl}>
|
||||||
<Button variant="secondary" className="mb-6">
|
<Button variant="secondary" className="mb-6">
|
||||||
← Späť na zoznam
|
← Späť na zoznam
|
||||||
</Button>
|
</Button>
|
||||||
@ -261,9 +405,20 @@ export default function ActivityDetailPage() {
|
|||||||
<h1 className="text-4xl font-bold text-[color:var(--fluent-text)] mb-2">
|
<h1 className="text-4xl font-bold text-[color:var(--fluent-text)] mb-2">
|
||||||
{activity.title}
|
{activity.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-[color:var(--fluent-text-secondary)]">
|
<div className="flex items-center gap-3">
|
||||||
{sportTypeLabels[activity.sportType] || activity.sportType}
|
<p className="text-xl text-[color:var(--fluent-text-secondary)]">
|
||||||
</p>
|
{sportTypeLabels[activity.sportType] || activity.sportType}
|
||||||
|
</p>
|
||||||
|
{activity.isRecurring && activity.recurrenceFrequency !== "NONE" && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-3 py-1 text-sm font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="23 4 23 10 17 10"></polyline>
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||||||
|
</svg>
|
||||||
|
Pravidelná aktivita
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`px-4 py-2 text-sm font-medium text-white rounded-full ${statusInfo.color}`}
|
className={`px-4 py-2 text-sm font-medium text-white rounded-full ${statusInfo.color}`}
|
||||||
@ -273,7 +428,7 @@ export default function ActivityDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex gap-3 mt-6">
|
<div className="flex flex-wrap gap-3 mt-6">
|
||||||
{canJoin && (
|
{canJoin && (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@ -283,21 +438,64 @@ export default function ActivityDetailPage() {
|
|||||||
{joining ? "Prihlasovanie..." : "✓ Prihlásiť sa"}
|
{joining ? "Prihlasovanie..." : "✓ Prihlásiť sa"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isParticipating && !isOrganizer && (
|
{isParticipating && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={handleLeave}
|
onClick={handleLeave}
|
||||||
disabled={leaving}
|
disabled={leaving}
|
||||||
|
className="bg-red-50 hover:bg-red-100 text-red-600 dark:bg-red-950/30 dark:hover:bg-red-950/50 dark:text-red-400"
|
||||||
>
|
>
|
||||||
{leaving ? "Odhlasovanie..." : "✗ Odhlásiť sa"}
|
{leaving ? "Odhlasovanie..." : "✗ Odhlásiť sa"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isOrganizer && (
|
{isOrganizer && (
|
||||||
<span className="px-4 py-2 text-sm font-medium bg-[color:var(--fluent-accent)]/10 text-[color:var(--fluent-accent)] rounded-lg">
|
<>
|
||||||
👤 Organizátor
|
<span className="px-4 py-2 text-sm font-medium bg-[color:var(--fluent-accent)]/10 text-[color:var(--fluent-accent)] rounded-lg">
|
||||||
</span>
|
👤 Organizátor
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="bg-red-50 hover:bg-red-100 text-red-600 dark:bg-red-950/30 dark:hover:bg-red-950/50 dark:text-red-400 border-red-300 dark:border-red-700"
|
||||||
|
>
|
||||||
|
{deleting ? "Mažem..." : "🗑️ Zmazať aktivitu"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add guests section for participants */}
|
||||||
|
{isParticipating && activity.status === "OPEN" && (
|
||||||
|
<div className="mt-6 p-4 bg-blue-50/50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<div className="flex-1 max-w-xs">
|
||||||
|
<label htmlFor="guestCount" className="block text-xs font-medium text-[color:var(--fluent-text)] mb-2">
|
||||||
|
Počet hostí (okrem vás)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="guestCount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max={activity.maxParticipants - activity.currentParticipants + (activity.participations.find(p => p.user.id === currentUserId)?.guestCount || 0)}
|
||||||
|
value={guestCount}
|
||||||
|
onChange={(e) => setGuestCount(Math.max(0, parseInt(e.target.value) || 0))}
|
||||||
|
className="w-full px-4 py-2 border border-[color:var(--fluent-border)] rounded-lg bg-[color:var(--fluent-card-background)] text-[color:var(--fluent-text)] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleAddGuests}
|
||||||
|
disabled={addingGuests}
|
||||||
|
>
|
||||||
|
{addingGuests ? "Aktualizujem..." : "Aktualizovať"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[color:var(--fluent-text-secondary)] mt-2">
|
||||||
|
Celkom miest: Vy + {guestCount} {guestCount === 1 ? "hosť" : guestCount < 5 ? "hostia" : "hostí"} = {1 + guestCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -417,6 +615,158 @@ export default function ActivityDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Upcoming instances for recurring activities */}
|
||||||
|
{((activity.isRecurring && activity.recurrenceFrequency !== "NONE") || activity.parentActivityId) && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
className="cursor-pointer hover:bg-[color:var(--fluent-surface-secondary)] transition-colors"
|
||||||
|
onClick={() => setShowUpcoming(!showUpcoming)}
|
||||||
|
>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||||
|
</svg>
|
||||||
|
Nadchádzajúce termíny
|
||||||
|
{upcomingInstances.length > 0 && (
|
||||||
|
<span className="ml-2 px-2 py-0.5 text-xs font-medium bg-[color:var(--fluent-accent)]/20 text-[color:var(--fluent-accent)] rounded-full">
|
||||||
|
{upcomingInstances.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={`transition-transform ${showUpcoming ? 'rotate-180' : ''}`}
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
|
</svg>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
{showUpcoming && (
|
||||||
|
<CardContent>
|
||||||
|
{loadingUpcoming ? (
|
||||||
|
<p className="text-sm text-[color:var(--fluent-text-secondary)]">Načítavam...</p>
|
||||||
|
) : upcomingInstances.length === 0 ? (
|
||||||
|
<p className="text-sm text-[color:var(--fluent-text-secondary)]">Žiadne nadchádzajúce termíny</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{upcomingInstances.map((instance) => {
|
||||||
|
const instanceDate = new Date(instance.date);
|
||||||
|
const formattedDate = instanceDate.toLocaleDateString("sk-SK", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
const formattedTime = instanceDate.toLocaleTimeString("sk-SK", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
const isCurrentActivity = instance.id === activity.id;
|
||||||
|
const isParticipatingInInstance = instance.participations.some(
|
||||||
|
(p) => p.user.id === currentUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={instance.id}
|
||||||
|
className={`p-4 rounded-lg border transition-all ${
|
||||||
|
isCurrentActivity
|
||||||
|
? 'bg-[color:var(--fluent-accent)]/10 border-[color:var(--fluent-accent)]'
|
||||||
|
: 'bg-[color:var(--fluent-surface-secondary)] border-[color:var(--fluent-border)] hover:border-[color:var(--fluent-border-strong)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-[color:var(--fluent-accent)]">
|
||||||
|
{instanceDate.getDate()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[color:var(--fluent-text-secondary)] uppercase">
|
||||||
|
{instanceDate.toLocaleDateString("sk-SK", { month: "short" })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-[color:var(--fluent-text)]">
|
||||||
|
{formattedDate} o {formattedTime}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
|
||||||
|
{instance.currentParticipants}/{instance.maxParticipants} účastníkov
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isCurrentActivity && (
|
||||||
|
<span className="px-2 py-1 text-xs font-medium bg-[color:var(--fluent-accent)] text-white rounded">
|
||||||
|
Aktuálny termín
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
{!isCurrentActivity && (
|
||||||
|
<Link href={`/activities/${instance.id}`} className="flex-1">
|
||||||
|
<Button variant="secondary" className="w-full text-sm py-2">
|
||||||
|
Zobraziť detail
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{!isParticipatingInInstance && instance.currentParticipants < instance.maxParticipants && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="flex-1 text-sm py-2"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/api/activities/${instance.id}/join`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ guestCount: 0 }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
fetchUpcomingInstances();
|
||||||
|
alert("Prihlásený!");
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
alert(data.error || "Chyba pri prihlásení");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert("Chyba pri prihlásení");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Prihlásiť sa
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isParticipatingInInstance && (
|
||||||
|
<span className="flex-1 flex items-center justify-center gap-2 text-sm text-green-600 dark:text-green-400 font-medium">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
Prihlásený
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
{activity.latitude && activity.longitude && (
|
{activity.latitude && activity.longitude && (
|
||||||
<Card>
|
<Card>
|
||||||
@ -501,9 +851,7 @@ export default function ActivityDetailPage() {
|
|||||||
{/* Participants list */}
|
{/* Participants list */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>Prihlásení účastníci</CardTitle>
|
||||||
Účastníci ({activity.participations.length})
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@ -517,6 +865,11 @@ export default function ActivityDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[color:var(--fluent-text)]">
|
<p className="text-sm text-[color:var(--fluent-text)]">
|
||||||
{participation.user.name}
|
{participation.user.name}
|
||||||
|
{participation.guestCount > 0 && (
|
||||||
|
<span className="ml-2 text-xs text-[color:var(--fluent-text-secondary)]">
|
||||||
|
+{participation.guestCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{participation.user.id === activity.organizer.id && (
|
{participation.user.id === activity.organizer.id && (
|
||||||
<span className="ml-2 text-xs text-[color:var(--fluent-accent)]">
|
<span className="ml-2 text-xs text-[color:var(--fluent-accent)]">
|
||||||
(Organizátor)
|
(Organizátor)
|
||||||
@ -534,3 +887,4 @@ export default function ActivityDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -53,6 +53,12 @@ export default function CreateActivityPage() {
|
|||||||
maxAge: 99,
|
maxAge: 99,
|
||||||
price: 0,
|
price: 0,
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
|
isRecurring: false,
|
||||||
|
recurrenceFrequency: "NONE" as "NONE" | "DAILY" | "WEEKLY" | "MONTHLY",
|
||||||
|
recurrenceDays: [] as number[],
|
||||||
|
recurrenceEndDate: "",
|
||||||
|
autoJoinAll: false,
|
||||||
|
autoJoinGuestCount: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [location, setLocation] = useState({
|
const [location, setLocation] = useState({
|
||||||
@ -74,6 +80,11 @@ export default function CreateActivityPage() {
|
|||||||
setError("Minimálny vek nemôže byť väčší ako maximálny vek");
|
setError("Minimálny vek nemôže byť väčší ako maximálny vek");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (formData.isRecurring && formData.recurrenceFrequency === "WEEKLY" && formData.recurrenceDays.length === 0) {
|
||||||
|
setError("Pre týždenné opakovanie musíte vybrať aspoň jeden deň v týždni");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@ -101,6 +112,12 @@ export default function CreateActivityPage() {
|
|||||||
minAge: formData.minAge,
|
minAge: formData.minAge,
|
||||||
maxAge: formData.maxAge,
|
maxAge: formData.maxAge,
|
||||||
price: formData.price,
|
price: formData.price,
|
||||||
|
isRecurring: formData.isRecurring,
|
||||||
|
recurrenceFrequency: formData.isRecurring ? formData.recurrenceFrequency : "NONE",
|
||||||
|
recurrenceDays: formData.isRecurring && formData.recurrenceFrequency === "WEEKLY" ? formData.recurrenceDays : [],
|
||||||
|
recurrenceEndDate: formData.isRecurring && formData.recurrenceEndDate ? new Date(formData.recurrenceEndDate).toISOString() : undefined,
|
||||||
|
autoJoinAll: formData.isRecurring ? formData.autoJoinAll : false,
|
||||||
|
autoJoinGuestCount: formData.isRecurring && formData.autoJoinAll ? formData.autoJoinGuestCount : 0,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -543,6 +560,147 @@ export default function CreateActivityPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recurring Activity */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="flex items-center space-x-3 cursor-pointer mb-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="isRecurring"
|
||||||
|
checked={formData.isRecurring}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-5 h-5 rounded border-[color:var(--fluent-border)] text-[color:var(--fluent-accent)] focus:ring-2 focus:ring-[color:var(--fluent-accent)]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[color:var(--fluent-text)]">
|
||||||
|
Pravidelne opakovaná aktivita
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{formData.isRecurring && (
|
||||||
|
<div className="ml-8 space-y-4 p-4 bg-[color:var(--fluent-surface-secondary)] rounded-lg border border-[color:var(--fluent-border)]">
|
||||||
|
{/* Frequency */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
||||||
|
Frekvencia opakovania *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="recurrenceFrequency"
|
||||||
|
value={formData.recurrenceFrequency}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={formData.isRecurring}
|
||||||
|
className="w-full px-4 py-2.5 bg-[color:var(--fluent-surface)] 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)]"
|
||||||
|
>
|
||||||
|
<option value="NONE">Nevybrané</option>
|
||||||
|
<option value="DAILY">Denne</option>
|
||||||
|
<option value="WEEKLY">Týždenne</option>
|
||||||
|
<option value="MONTHLY">Mesačne</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Days of week for WEEKLY */}
|
||||||
|
{formData.recurrenceFrequency === "WEEKLY" && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
||||||
|
Dni v týždni *
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
{["Ne", "Po", "Ut", "St", "Št", "Pi", "So"].map((day, index) => {
|
||||||
|
const isSelected = formData.recurrenceDays.includes(index);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
recurrenceDays: isSelected
|
||||||
|
? prev.recurrenceDays.filter(d => d !== index)
|
||||||
|
: [...prev.recurrenceDays, index].sort(),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className={`
|
||||||
|
px-3 py-2 rounded-lg text-sm font-medium transition-all
|
||||||
|
${
|
||||||
|
isSelected
|
||||||
|
? 'bg-[color:var(--fluent-accent)] text-white'
|
||||||
|
: 'bg-[color:var(--fluent-surface)] border border-[color:var(--fluent-border)] text-[color:var(--fluent-text)] hover:border-[color:var(--fluent-border-strong)]'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* End date */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
||||||
|
Dátum ukončenia opakovania (voliteľné)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
name="recurrenceEndDate"
|
||||||
|
value={formData.recurrenceEndDate}
|
||||||
|
onChange={handleChange}
|
||||||
|
min={formData.date || new Date().toISOString().split("T")[0]}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[color:var(--fluent-text-secondary)] mt-1">
|
||||||
|
Ak nezadáte, aktivity sa budú generovať na 1 rok dopredu
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-join checkbox */}
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center space-x-3 cursor-pointer mb-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.autoJoinAll}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
autoJoinAll: e.target.checked,
|
||||||
|
autoJoinGuestCount: e.target.checked ? prev.autoJoinGuestCount : 0,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="w-5 h-5 rounded border-[color:var(--fluent-border)] text-[color:var(--fluent-accent)] focus:ring-2 focus:ring-[color:var(--fluent-accent)]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[color:var(--fluent-text)]">
|
||||||
|
Automaticky sa prihlás na všetky vygenerované aktivity
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{formData.autoJoinAll && (
|
||||||
|
<div className="ml-8">
|
||||||
|
<label className="block text-sm font-medium mb-2 text-[color:var(--fluent-text)]">
|
||||||
|
Počet hostí (okrem teba)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max={formData.maxParticipants - 1}
|
||||||
|
value={formData.autoJoinGuestCount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = parseInt(e.target.value) || 0;
|
||||||
|
const maxGuests = formData.maxParticipants - 1;
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
autoJoinGuestCount: Math.min(value, maxGuests),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[color:var(--fluent-text-secondary)] mt-1">
|
||||||
|
Počet ľudí, ktorých berieš so sebou (hosťa) - max {formData.maxParticipants - 1}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<div className="flex gap-4 justify-end">
|
<div className="flex gap-4 justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -20,6 +20,9 @@ interface Activity {
|
|||||||
locationName: string | null;
|
locationName: string | null;
|
||||||
latitude: number | null;
|
latitude: number | null;
|
||||||
longitude: number | null;
|
longitude: number | null;
|
||||||
|
isRecurring: boolean;
|
||||||
|
recurrenceFrequency: string;
|
||||||
|
parentActivityId: string | null;
|
||||||
venue?: {
|
venue?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -77,16 +80,33 @@ function ActivityCard({ activity }: { activity: Activity }) {
|
|||||||
const freeSpots = activity.maxParticipants - activity.currentParticipants;
|
const freeSpots = activity.maxParticipants - activity.currentParticipants;
|
||||||
const statusInfo = statusLabels[activity.status] || statusLabels.OPEN;
|
const statusInfo = statusLabels[activity.status] || statusLabels.OPEN;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
sessionStorage.setItem("activityListSource", "/activities");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/activities/${activity.id}`}>
|
<Link href={`/activities/${activity.id}`} onClick={handleClick}>
|
||||||
<Card hover className="h-full">
|
<Card hover className="h-full">
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-xl font-bold text-[color:var(--fluent-text)] mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{activity.title}
|
<h3 className="text-xl font-bold text-[color:var(--fluent-text)]">
|
||||||
</h3>
|
{activity.title}
|
||||||
|
</h3>
|
||||||
|
{((activity.isRecurring && activity.recurrenceFrequency !== "NONE") || activity.parentActivityId) && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="23 4 23 10 17 10"></polyline>
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||||||
|
</svg>
|
||||||
|
Opakovaná
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
|
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
|
||||||
{sportTypeLabels[activity.sportType] || activity.sportType}
|
{sportTypeLabels[activity.sportType] || activity.sportType}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -142,6 +142,60 @@ export default function SignInPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* OAuth Divider */}
|
||||||
|
<div className="mt-8 mb-8">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-[color:var(--fluent-border)]"></div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-4 bg-[color:var(--fluent-card-background)] text-[color:var(--fluent-text-secondary)] font-medium">
|
||||||
|
Alebo sa prihláste pomocou
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OAuth Buttons */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await signIn.social({
|
||||||
|
provider: 'google',
|
||||||
|
callbackURL: 'http://localhost:3000/dashboard',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full flex items-center justify-center gap-3 px-6 py-3.5 border-2 border-[color:var(--fluent-border-strong)] rounded-lg font-semibold text-base text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-subtle)] hover:border-gray-400 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||||
|
</svg>
|
||||||
|
Pokračovať s Google
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await signIn.social({
|
||||||
|
provider: 'facebook',
|
||||||
|
callbackURL: 'http://localhost:3000/dashboard',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full flex items-center justify-center gap-3 px-6 py-3.5 border-2 border-[color:var(--fluent-border-strong)] rounded-lg font-semibold text-base text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-subtle)] hover:border-gray-400 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="#1877F2" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||||
|
</svg>
|
||||||
|
Pokračovať s Facebook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
<p className="text-base text-[color:var(--fluent-text-secondary)]">
|
<p className="text-base text-[color:var(--fluent-text-secondary)]">
|
||||||
Nemáte účet?{' '}
|
Nemáte účet?{' '}
|
||||||
|
|||||||
@ -228,6 +228,60 @@ export default function SignUpPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* OAuth Divider */}
|
||||||
|
<div className="mt-8 mb-8">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-[color:var(--fluent-border)]"></div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-4 bg-[color:var(--fluent-card-background)] text-[color:var(--fluent-text-secondary)] font-medium">
|
||||||
|
Alebo sa zaregistrujte pomocou
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OAuth Buttons */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await signIn.social({
|
||||||
|
provider: 'google',
|
||||||
|
callbackURL: 'http://localhost:3000/dashboard',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full flex items-center justify-center gap-3 px-6 py-3.5 border-2 border-[color:var(--fluent-border-strong)] rounded-lg font-semibold text-base text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-subtle)] hover:border-gray-400 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||||
|
</svg>
|
||||||
|
Pokračovať s Google
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await signIn.social({
|
||||||
|
provider: 'facebook',
|
||||||
|
callbackURL: 'http://localhost:3000/dashboard',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full flex items-center justify-center gap-3 px-6 py-3.5 border-2 border-[color:var(--fluent-border-strong)] rounded-lg font-semibold text-base text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-subtle)] hover:border-gray-400 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="#1877F2" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||||
|
</svg>
|
||||||
|
Pokračovať s Facebook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
<p className="text-base text-[color:var(--fluent-text-secondary)]">
|
<p className="text-base text-[color:var(--fluent-text-secondary)]">
|
||||||
Už máte účet?{' '}
|
Už máte účet?{' '}
|
||||||
|
|||||||
364
apps/frontend/src/app/my-activities/page.tsx
Normal file
364
apps/frontend/src/app/my-activities/page.tsx
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
|
||||||
|
|
||||||
|
interface Activity {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
sportType: string;
|
||||||
|
skillLevel: string;
|
||||||
|
date: string;
|
||||||
|
duration: number;
|
||||||
|
maxParticipants: number;
|
||||||
|
currentParticipants: number;
|
||||||
|
status: string;
|
||||||
|
location: string;
|
||||||
|
locationName: string | null;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
gender: string;
|
||||||
|
minAge: number;
|
||||||
|
maxAge: number;
|
||||||
|
price: number;
|
||||||
|
isRecurring: boolean;
|
||||||
|
recurrenceFrequency: string;
|
||||||
|
parentActivityId: string | null;
|
||||||
|
venue?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
city: string;
|
||||||
|
address: string;
|
||||||
|
} | null;
|
||||||
|
organizer: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string | null;
|
||||||
|
};
|
||||||
|
participations: {
|
||||||
|
id: string;
|
||||||
|
guestCount: number;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string | null;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MyActivitiesResponse {
|
||||||
|
created: Activity[];
|
||||||
|
joined: Activity[];
|
||||||
|
stats: {
|
||||||
|
totalCreated: number;
|
||||||
|
totalJoined: number;
|
||||||
|
upcomingCreated: number;
|
||||||
|
upcomingJoined: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sportTypeLabels: Record<string, string> = {
|
||||||
|
FOOTBALL: "⚽ Futbal",
|
||||||
|
BASKETBALL: "🏀 Basketbal",
|
||||||
|
TENNIS: "🎾 Tenis",
|
||||||
|
VOLLEYBALL: "🏐 Volejbal",
|
||||||
|
BADMINTON: "🏸 Bedminton",
|
||||||
|
TABLE_TENNIS: "🏓 Stolný tenis",
|
||||||
|
RUNNING: "🏃 Beh",
|
||||||
|
CYCLING: "🚴 Cyklistika",
|
||||||
|
SWIMMING: "🏊 Plávanie",
|
||||||
|
GYM: "💪 Posilňovňa",
|
||||||
|
OTHER: "🎯 Iné",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, { label: string; color: string }> = {
|
||||||
|
OPEN: { label: "Otvorená", color: "bg-green-500" },
|
||||||
|
FULL: { label: "Plná", color: "bg-orange-500" },
|
||||||
|
CANCELLED: { label: "Zrušená", color: "bg-red-500" },
|
||||||
|
COMPLETED: { label: "Ukončená", color: "bg-gray-500" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MyActivitiesPage() {
|
||||||
|
const [data, setData] = useState<MyActivitiesResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [activeTab, setActiveTab] = useState<"created" | "joined">("created");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMyActivities();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchMyActivities = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/api/activities/my`,
|
||||||
|
{
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Chyba pri načítaní aktivít");
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderActivity = (activity: Activity, isCreator: boolean) => {
|
||||||
|
const activityDate = new Date(activity.date);
|
||||||
|
const formattedDate = activityDate.toLocaleDateString("sk-SK", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
const formattedTime = activityDate.toLocaleTimeString("sk-SK", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
const isPast = activityDate < new Date();
|
||||||
|
const statusInfo = statusLabels[activity.status];
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
sessionStorage.setItem("activityListSource", "/my-activities");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={activity.id} hover className="relative">
|
||||||
|
<Link href={`/activities/${activity.id}`} onClick={handleClick}>
|
||||||
|
<div className="absolute top-4 right-4 flex gap-2">
|
||||||
|
{((activity.isRecurring && activity.recurrenceFrequency !== "NONE") || activity.parentActivityId) && (
|
||||||
|
<span className="px-2 py-1 text-xs font-medium bg-purple-500/20 text-purple-400 rounded flex items-center gap-1">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
|
||||||
|
</svg>
|
||||||
|
Opakovaná
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium text-white rounded ${statusInfo.color}`}>
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="text-center min-w-[60px]">
|
||||||
|
<div className="text-3xl font-bold text-[color:var(--fluent-accent)]">
|
||||||
|
{activityDate.getDate()}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[color:var(--fluent-text-secondary)] uppercase">
|
||||||
|
{activityDate.toLocaleDateString("sk-SK", { month: "short" })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle className="text-xl mb-2">{activity.title}</CardTitle>
|
||||||
|
<div className="flex flex-wrap gap-2 text-sm text-[color:var(--fluent-text-secondary)]">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{sportTypeLabels[activity.sportType] || activity.sportType}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formattedDate}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formattedTime}</span>
|
||||||
|
</div>
|
||||||
|
{isCreator && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="px-2 py-1 text-xs font-medium bg-blue-500/20 text-blue-400 rounded">
|
||||||
|
👤 Organizátor
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{activity.currentParticipants}/{activity.maxParticipants} účastníkov
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<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>
|
||||||
|
<span>{activity.locationName || activity.location}</span>
|
||||||
|
</div>
|
||||||
|
{activity.price > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="12" y1="1" x2="12" y2="23"></line>
|
||||||
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{activity.price} €</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<p className="text-center text-[color:var(--fluent-text-secondary)]">Načítavam...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<p className="text-center text-red-500">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayActivities = activeTab === "created" ? data.created : data.joined;
|
||||||
|
const upcomingCount = activeTab === "created" ? data.stats.upcomingCreated : data.stats.upcomingJoined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold text-[color:var(--fluent-text)] mb-2">
|
||||||
|
Moje aktivity
|
||||||
|
</h1>
|
||||||
|
<p className="text-[color:var(--fluent-text-secondary)]">
|
||||||
|
Prehľad všetkých tvojich aktivít
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-[color:var(--fluent-accent)] mb-1">
|
||||||
|
{data.stats.totalCreated}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[color:var(--fluent-text-secondary)]">
|
||||||
|
Vytvorené
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-[color:var(--fluent-accent)] mb-1">
|
||||||
|
{data.stats.totalJoined}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[color:var(--fluent-text-secondary)]">
|
||||||
|
Prihlásené
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-green-500 mb-1">
|
||||||
|
{data.stats.upcomingCreated}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[color:var(--fluent-text-secondary)]">
|
||||||
|
Nadchádzajúce (vytvorené)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-green-500 mb-1">
|
||||||
|
{data.stats.upcomingJoined}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[color:var(--fluent-text-secondary)]">
|
||||||
|
Nadchádzajúce (prihlásené)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-4 mb-6 border-b border-[color:var(--fluent-border)]">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("created")}
|
||||||
|
className={`px-6 py-3 font-medium transition-all ${
|
||||||
|
activeTab === "created"
|
||||||
|
? "text-[color:var(--fluent-accent)] border-b-2 border-[color:var(--fluent-accent)]"
|
||||||
|
: "text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Vytvorené ({data.stats.totalCreated})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("joined")}
|
||||||
|
className={`px-6 py-3 font-medium transition-all ${
|
||||||
|
activeTab === "joined"
|
||||||
|
? "text-[color:var(--fluent-accent)] border-b-2 border-[color:var(--fluent-accent)]"
|
||||||
|
: "text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Prihlásené ({data.stats.totalJoined})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activities Grid */}
|
||||||
|
{displayActivities.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-6xl mb-4">🏃</div>
|
||||||
|
<h3 className="text-xl font-semibold text-[color:var(--fluent-text)] mb-2">
|
||||||
|
{activeTab === "created" ? "Zatiaľ si nevytvoril žiadne aktivity" : "Zatiaľ si sa neprihlásil na žiadne aktivity"}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[color:var(--fluent-text-secondary)] mb-6">
|
||||||
|
{activeTab === "created"
|
||||||
|
? "Vytvor svoju prvú športovú aktivitu a pozvi ostatných"
|
||||||
|
: "Prehliadni dostupné aktivity a pripoj sa k niektorej"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<Link href={activeTab === "created" ? "/activities/create" : "/activities"}>
|
||||||
|
<Button variant="primary">
|
||||||
|
{activeTab === "created" ? "Vytvoriť aktivitu" : "Prehliadať aktivity"}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{displayActivities.map((activity) => renderActivity(activity, activeTab === "created"))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
319
apps/frontend/src/app/venues/page.tsx
Normal file
319
apps/frontend/src/app/venues/page.tsx
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { GoogleMap, useLoadScript, Marker, InfoWindow } from "@react-google-maps/api";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
interface Activity {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
sportType: string;
|
||||||
|
date: string;
|
||||||
|
duration: number;
|
||||||
|
maxParticipants: number;
|
||||||
|
currentParticipants: number;
|
||||||
|
location: string;
|
||||||
|
locationName: string | null;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
price: number;
|
||||||
|
organizer: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sportTypeLabels: Record<string, string> = {
|
||||||
|
FOOTBALL: "⚽ Futbal",
|
||||||
|
BASKETBALL: "🏀 Basketbal",
|
||||||
|
TENNIS: "🎾 Tenis",
|
||||||
|
VOLLEYBALL: "🏐 Volejbal",
|
||||||
|
BADMINTON: "🏸 Bedminton",
|
||||||
|
TABLE_TENNIS: "🏓 Stolný tenis",
|
||||||
|
RUNNING: "🏃 Beh",
|
||||||
|
CYCLING: "🚴 Cyklistika",
|
||||||
|
SWIMMING: "🏊 Plávanie",
|
||||||
|
GYM: "💪 Posilňovňa",
|
||||||
|
OTHER: "🎯 Iné",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapContainerStyle = {
|
||||||
|
width: "100%",
|
||||||
|
height: "calc(100vh - 120px)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultCenter = {
|
||||||
|
lat: 48.1486, // Bratislava
|
||||||
|
lng: 17.1077,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VenuesPage() {
|
||||||
|
const { isLoaded, loadError } = useLoadScript({
|
||||||
|
googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [activities, setActivities] = useState<Activity[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
|
||||||
|
const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||||
|
const [mapCenter, setMapCenter] = useState(defaultCenter);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchActivities();
|
||||||
|
getUserLocation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getUserLocation = () => {
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
const location = {
|
||||||
|
lat: position.coords.latitude,
|
||||||
|
lng: position.coords.longitude,
|
||||||
|
};
|
||||||
|
setUserLocation(location);
|
||||||
|
setMapCenter(location);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error("Error getting location:", error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchActivities = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/api/activities?status=OPEN`
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Chyba pri načítaní aktivít");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
// Filter activities that have coordinates
|
||||||
|
const activitiesWithCoords = data.filter(
|
||||||
|
(a: Activity) => a.latitude != null && a.longitude != null
|
||||||
|
);
|
||||||
|
setActivities(activitiesWithCoords);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMarkerClick = useCallback((activity: Activity) => {
|
||||||
|
setSelectedActivity(activity);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onInfoWindowClose = useCallback(() => {
|
||||||
|
setSelectedActivity(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<p className="text-center text-[color:var(--fluent-text-secondary)]">Načítavam...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<p className="text-center text-red-500">Chyba pri načítaní mapy</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoaded) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<p className="text-center text-[color:var(--fluent-text-secondary)]">Načítavam mapu...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<p className="text-center text-red-500">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-4xl font-bold text-[color:var(--fluent-text)] mb-2">
|
||||||
|
Mapa aktivít
|
||||||
|
</h1>
|
||||||
|
<p className="text-[color:var(--fluent-text-secondary)]">
|
||||||
|
{activities.length} aktivít v tvojom okolí
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map */}
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<GoogleMap
|
||||||
|
mapContainerStyle={mapContainerStyle}
|
||||||
|
center={mapCenter}
|
||||||
|
zoom={13}
|
||||||
|
options={{
|
||||||
|
zoomControl: true,
|
||||||
|
streetViewControl: false,
|
||||||
|
mapTypeControl: false,
|
||||||
|
fullscreenControl: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* User location marker */}
|
||||||
|
{userLocation && (
|
||||||
|
<Marker
|
||||||
|
position={userLocation}
|
||||||
|
icon={{
|
||||||
|
url: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Ccircle cx='10' cy='10' r='8' fill='%234285F4' stroke='white' stroke-width='2'/%3E%3C/svg%3E",
|
||||||
|
scaledSize: { width: 20, height: 20 } as google.maps.Size,
|
||||||
|
anchor: { x: 10, y: 10 } as google.maps.Point,
|
||||||
|
}}
|
||||||
|
title="Tvoja poloha"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activity markers */}
|
||||||
|
{activities.map((activity) => (
|
||||||
|
<Marker
|
||||||
|
key={activity.id}
|
||||||
|
position={{
|
||||||
|
lat: activity.latitude,
|
||||||
|
lng: activity.longitude,
|
||||||
|
}}
|
||||||
|
onClick={() => onMarkerClick(activity)}
|
||||||
|
icon={{
|
||||||
|
url: getMarkerIcon(activity.sportType),
|
||||||
|
scaledSize: { width: 40, height: 40 } as google.maps.Size,
|
||||||
|
}}
|
||||||
|
title={activity.title}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Info Window */}
|
||||||
|
{selectedActivity && (
|
||||||
|
<InfoWindow
|
||||||
|
position={{
|
||||||
|
lat: selectedActivity.latitude,
|
||||||
|
lng: selectedActivity.longitude,
|
||||||
|
}}
|
||||||
|
onCloseClick={onInfoWindowClose}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '8px', maxWidth: '280px' }}>
|
||||||
|
<h3 style={{ fontWeight: 'bold', fontSize: '18px', marginBottom: '8px', color: '#000' }}>
|
||||||
|
{selectedActivity.title}
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '14px', marginBottom: '12px', color: '#333' }}>
|
||||||
|
<p style={{ display: 'flex', alignItems: 'center', gap: '8px', margin: 0 }}>
|
||||||
|
{sportTypeLabels[selectedActivity.sportType] || selectedActivity.sportType}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: 0 }}>
|
||||||
|
{new Date(selectedActivity.date).toLocaleDateString("sk-SK", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
})}{" "}
|
||||||
|
o{" "}
|
||||||
|
{new Date(selectedActivity.date).toLocaleTimeString("sk-SK", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: 0 }}>
|
||||||
|
{selectedActivity.currentParticipants}/{selectedActivity.maxParticipants} účastníkov
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#666', margin: 0 }}>
|
||||||
|
📍 {selectedActivity.locationName || selectedActivity.location}
|
||||||
|
</p>
|
||||||
|
{selectedActivity.price > 0 && (
|
||||||
|
<p style={{ fontWeight: '500', color: '#16a34a', margin: 0 }}>
|
||||||
|
{selectedActivity.price} €
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link href={`/activities/${selectedActivity.id}`}>
|
||||||
|
<button style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#2563eb',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontWeight: '500',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}>
|
||||||
|
Zobraziť detail
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</InfoWindow>
|
||||||
|
)}
|
||||||
|
</GoogleMap>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<Card className="mt-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Legenda</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-500 border-2 border-white"></div>
|
||||||
|
<span className="text-sm">Tvoja poloha</span>
|
||||||
|
</div>
|
||||||
|
{Object.entries(sportTypeLabels).map(([key, label]) => (
|
||||||
|
<div key={key} className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">{label.split(" ")[0]}</span>
|
||||||
|
<span className="text-sm">{label.split(" ").slice(1).join(" ")}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get marker icon based on sport type
|
||||||
|
function getMarkerIcon(sportType: string): string {
|
||||||
|
// Using emoji as markers - you can replace with custom icons
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
FOOTBALL: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E⚽%3C/text%3E%3C/svg%3E",
|
||||||
|
BASKETBALL: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🏀%3C/text%3E%3C/svg%3E",
|
||||||
|
TENNIS: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🎾%3C/text%3E%3C/svg%3E",
|
||||||
|
VOLLEYBALL: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🏐%3C/text%3E%3C/svg%3E",
|
||||||
|
BADMINTON: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🏸%3C/text%3E%3C/svg%3E",
|
||||||
|
TABLE_TENNIS: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🏓%3C/text%3E%3C/svg%3E",
|
||||||
|
RUNNING: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🏃%3C/text%3E%3C/svg%3E",
|
||||||
|
CYCLING: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🚴%3C/text%3E%3C/svg%3E",
|
||||||
|
SWIMMING: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🏊%3C/text%3E%3C/svg%3E",
|
||||||
|
GYM: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E💪%3C/text%3E%3C/svg%3E",
|
||||||
|
OTHER: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🎯%3C/text%3E%3C/svg%3E",
|
||||||
|
};
|
||||||
|
|
||||||
|
return icons[sportType] || icons.OTHER;
|
||||||
|
}
|
||||||
@ -16,7 +16,7 @@ export default function Navigation() {
|
|||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: '/', label: 'Domov' },
|
{ href: '/', label: 'Domov' },
|
||||||
{ href: '/activities', label: 'Aktivity' },
|
{ href: '/activities', label: 'Aktivity' },
|
||||||
{ href: '/venues', label: 'Športoviská' },
|
{ href: '/venues', label: 'Mapa aktivít' },
|
||||||
{ href: '/my-activities', label: 'Moje aktivity' },
|
{ href: '/my-activities', label: 'Moje aktivity' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -89,7 +89,7 @@ export default function Navigation() {
|
|||||||
<div className="relative" ref={userMenuRef}>
|
<div className="relative" ref={userMenuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||||
className="flex items-center space-x-3 px-4 py-2 rounded-lg hover-glow reveal-effect transition-all duration-200"
|
className="flex items-center space-x-3 px-4 py-2 rounded-lg hover-glow reveal-effect transition-all duration-200 cursor-pointer"
|
||||||
style={{ boxShadow: 'var(--shadow-sm)' }}
|
style={{ boxShadow: 'var(--shadow-sm)' }}
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 gradient-primary rounded-full flex items-center justify-center">
|
<div className="w-10 h-10 gradient-primary rounded-full flex items-center justify-center">
|
||||||
@ -138,7 +138,7 @@ export default function Navigation() {
|
|||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className="w-full text-left px-4 py-3 text-base font-medium text-red-600 dark:text-red-400 hover:bg-red-50/50 dark:hover:bg-red-950/20 transition-all"
|
className="w-full text-left px-4 py-3 text-base font-medium text-red-600 dark:text-red-400 hover:bg-red-50/50 dark:hover:bg-red-950/20 transition-all cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -184,7 +184,7 @@ export default function Navigation() {
|
|||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
className="p-2 rounded-md hover:bg-[color:var(--fluent-surface-secondary)] transition-all duration-200"
|
className="p-2 rounded-md hover:bg-[color:var(--fluent-surface-secondary)] transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="w-6 h-6 flex flex-col justify-center items-center space-y-1.5">
|
<div className="w-6 h-6 flex flex-col justify-center items-center space-y-1.5">
|
||||||
<span
|
<span
|
||||||
@ -283,7 +283,7 @@ export default function Navigation() {
|
|||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className="w-full block px-4 py-3 text-base text-left font-semibold text-red-600 dark:text-red-400 hover:bg-red-50/50 dark:hover:bg-red-950/20 rounded-lg hover-glow reveal-effect transition-all duration-200"
|
className="w-full block px-4 py-3 text-base text-left font-semibold text-red-600 dark:text-red-400 hover:bg-red-50/50 dark:hover:bg-red-950/20 rounded-lg hover-glow reveal-effect transition-all duration-200 cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
boxShadow: 'var(--shadow-sm)',
|
boxShadow: 'var(--shadow-sm)',
|
||||||
backdropFilter: 'blur(20px)',
|
backdropFilter: 'blur(20px)',
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export function ThemeToggle() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="p-2 rounded-lg acrylic hover-glow reveal-effect transition-all"
|
className="p-2 rounded-lg acrylic hover-glow reveal-effect transition-all cursor-pointer"
|
||||||
aria-label="Prepnúť tému"
|
aria-label="Prepnúť tému"
|
||||||
>
|
>
|
||||||
{theme === 'light' ? (
|
{theme === 'light' ? (
|
||||||
|
|||||||
@ -34,14 +34,16 @@ export const Card: React.FC<CardProps> = ({
|
|||||||
interface CardHeaderProps {
|
interface CardHeaderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardHeader: React.FC<CardHeaderProps> = ({
|
export const CardHeader: React.FC<CardHeaderProps> = ({
|
||||||
children,
|
children,
|
||||||
className = ''
|
className = '',
|
||||||
|
onClick
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={`mb-5 ${className}`}>
|
<div className={`mb-5 ${className}`} onClick={onClick}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user