diff --git a/USER_STORIES.md b/USER_STORIES.md index be9eca5..1dd5b59 100644 --- a/USER_STORIES.md +++ b/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 - ✅ 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 +- ✅ 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: - ✅ API endpoint funguje @@ -114,6 +120,13 @@ aby som našiel spoluhráčov - ✅ Google Maps autocomplete pre adresu - ✅ Uloženie lokácie, GPS súradníc a názvu miesta - ✅ Všetky US-012 filter polia v create forme +- ✅ 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 -**Status:** 📋 PLANNED +**Status:** ✅ HOTOVÉ Ako používateľ 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 +<<<<<<< HEAD **Vývojár:** - Jozef Kovalčín +======= +**Vývojár:** Jozef Kovalčín +>>>>>>> ad142ec (feat: recurring activities, my activities page, map view with markers) ### Tasky: -- ⏸️ BetterAuth konfigurácia OAuth providers (Google, Facebook) -- ⏸️ Google OAuth setup (Client ID, Secret) -- ⏸️ Facebook OAuth setup (App ID, Secret) -- ⏸️ Prisma schema: rozšírenie User modelu (providerId, provider) -- ⏸️ API: OAuth callback handling -- ⏸️ Tlačidlá "Prihlásiť cez Google/Facebook" na login/register stránke -- ⏸️ Mapovanie OAuth dát na User profil (email, meno, avatar) -- ⏸️ Handling existujúceho účtu (merge alebo error) -- ⏸️ Session management pre OAuth users -- ⏸️ Responzívne OAuth tlačidlá +- ✅ BetterAuth konfigurácia OAuth providers (Google, Facebook, Apple) +- ✅ Google OAuth setup (Client ID, Secret) +- ✅ Facebook OAuth setup (App ID, Secret) +- ✅ Apple OAuth setup (Client ID, Secret) +- ✅ Prisma schema: rozšírenie Account modelu (accessTokenExpiresAt, refreshTokenExpiresAt, scope) +- ✅ Account linking konfigurácia (trustedProviders) +- ✅ API: OAuth callback handling (redirect na frontend URL) +- ✅ Error page redirect (na frontend signin page) +- ✅ Tlačidlá "Prihlásiť cez Google/Facebook" na login/register stránke +- ✅ 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: -- ⏸️ Google login funguje -- ⏸️ Facebook login funguje -- ⏸️ Automatické vytvorenie profilu -- ⏸️ Merge s existujúcim emailom (optional) +- ✅ Google login funguje +- ✅ Facebook login funguje (vyžaduje konfiguráciu Facebook Developer App) +- ✅ Apple login funguje (vyžaduje konfiguráciu Apple Developer Account) +- ✅ Automatické vytvorenie profilu +- ✅ Account linking medzi providers (trusted: Google, Facebook, Apple) + --- ## US-009: Mapa s lokalitami aktivít -**Status:** 🔄 WIP (Work In Progress) +**Status:** ✅ HOTOVÉ Ako používateľ 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 - ✅ Responzívna mapa (mobile/desktop) - ✅ Custom styling pre autocomplete dropdown -- ⏸️ Markery pre jednotlivé aktivity na mape -- ⏸️ Info window pri kliknutí na marker (názov, šport, čas) +- ✅ Stránka /venues s full-screen mapou (premenovaná na "Mapa aktivít") +- ✅ 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 ### 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) - ✅ Deep linking do Google Maps/Apple Maps - ✅ Custom styled autocomplete dropdown -- ⏸️ Mapa na zozname aktivít -- ⏸️ Klikateľné markery +- ✅ Full-screen mapa na /venues s všetkými aktivitami +- ✅ 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 - ✅ Hotové (Completed) diff --git a/apps/backend/next.config.mjs b/apps/backend/next.config.mjs index 52560a8..5c1cd31 100644 --- a/apps/backend/next.config.mjs +++ b/apps/backend/next.config.mjs @@ -2,7 +2,7 @@ const nextConfig = { reactStrictMode: true, experimental: { - webpackBuildWorker: true, + webpackBuildWorker: false, }, async headers() { return [ diff --git a/apps/backend/package.json b/apps/backend/package.json index 81dd8b5..8f622a5 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -6,7 +6,7 @@ "node": ">=20.16.0" }, "scripts": { - "dev": "next dev -p 3001", + "dev": "NODE_OPTIONS='--no-warnings' next dev -p 3001", "build": "next build", "start": "next start -p 3001", "lint": "next lint", diff --git a/apps/backend/prisma/migrations/20251113142203_update_account_oauth_fields/migration.sql b/apps/backend/prisma/migrations/20251113142203_update_account_oauth_fields/migration.sql new file mode 100644 index 0000000..9bfeb83 --- /dev/null +++ b/apps/backend/prisma/migrations/20251113142203_update_account_oauth_fields/migration.sql @@ -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; diff --git a/apps/backend/prisma/migrations/20251113143816_add_guest_count_to_participation/migration.sql b/apps/backend/prisma/migrations/20251113143816_add_guest_count_to_participation/migration.sql new file mode 100644 index 0000000..1f443f2 --- /dev/null +++ b/apps/backend/prisma/migrations/20251113143816_add_guest_count_to_participation/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Participation" ADD COLUMN "guestCount" INTEGER NOT NULL DEFAULT 0; diff --git a/apps/backend/prisma/migrations/20251113150009_add_recurring_activities/migration.sql b/apps/backend/prisma/migrations/20251113150009_add_recurring_activities/migration.sql new file mode 100644 index 0000000..22cf82d --- /dev/null +++ b/apps/backend/prisma/migrations/20251113150009_add_recurring_activities/migration.sql @@ -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; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 508361a..dbb5787 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -70,7 +70,9 @@ model Account { accessToken String? @db.Text refreshToken String? @db.Text idToken String? @db.Text - expiresAt DateTime? + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? password String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @@ -176,6 +178,14 @@ model Venue { @@index([sportTypes]) } +// Recurrence frequency enumeration +enum RecurrenceFrequency { + NONE + DAILY + WEEKLY + MONTHLY +} + // Activity (Športová aktivita) model model Activity { id String @id @default(cuid()) @@ -202,6 +212,13 @@ model Activity { latitude 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 venueId String? organizerId String @@ -212,6 +229,8 @@ model Activity { venue Venue? @relation(fields: [venueId], references: [id], onDelete: SetNull) organizer User @relation(fields: [organizerId], references: [id], onDelete: Cascade) participations Participation[] + parentActivity Activity? @relation("RecurringActivities", fields: [parentActivityId], references: [id], onDelete: SetNull) + childActivities Activity[] @relation("RecurringActivities") @@index([sportType]) @@index([date]) @@ -219,6 +238,8 @@ model Activity { @@index([venueId]) @@index([gender]) @@index([skillLevel]) + @@index([parentActivityId]) + @@index([isRecurring]) } // Participation (Účasť na aktivite) model @@ -227,6 +248,7 @@ model Participation { userId String activityId String status ParticipationStatus @default(CONFIRMED) + guestCount Int @default(0) // Number of additional guests brought by this participant joinedAt DateTime @default(now()) // Relations diff --git a/apps/backend/src/app/api/activities/[id]/join/route.ts b/apps/backend/src/app/api/activities/[id]/join/route.ts index f14d86b..4734ddc 100644 --- a/apps/backend/src/app/api/activities/[id]/join/route.ts +++ b/apps/backend/src/app/api/activities/[id]/join/route.ts @@ -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({ where: { id: id }, 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 const existingParticipation = await prisma.participation.findUnique({ where: { @@ -57,36 +67,79 @@ export async function POST( }); 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( - { error: "Už ste prihlásený na túto aktivitu" }, + { error: `K dispozícii je len ${availableSpots} voľných miest` }, { status: 400 } ); } - // Create participation - const participation = await prisma.participation.create({ + // Create new participation + await prisma.participation.create({ data: { userId: session.user.id, activityId: id, 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({ where: { id: id }, data: { - currentParticipants: { - increment: 1, - }, - status: - activity.currentParticipants + 1 >= activity.maxParticipants - ? "FULL" - : "OPEN", + currentParticipants: newParticipantCount, + status: newParticipantCount >= activity.maxParticipants ? "FULL" : "OPEN", }, }); - return NextResponse.json({ participation, activity: updatedActivity }); + return NextResponse.json({ activity: updatedActivity }); } catch (error) { console.error("Error joining activity:", error); 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( - { error: "Organizátor nemôže opustiť vlastnú aktivitu" }, + { error: "Nie ste prihlásený na túto aktivitu" }, { status: 400 } ); } + // Calculate total participants to remove (user + guests) + const totalToRemove = 1 + participation.guestCount; + // Delete participation await prisma.participation.delete({ where: { @@ -145,7 +211,7 @@ export async function DELETE( where: { id: id }, data: { currentParticipants: { - decrement: 1, + decrement: totalToRemove, }, status: "OPEN", }, @@ -160,3 +226,4 @@ export async function DELETE( ); } } + diff --git a/apps/backend/src/app/api/activities/[id]/route.ts b/apps/backend/src/app/api/activities/[id]/route.ts index 3002b2e..58c95ee 100644 --- a/apps/backend/src/app/api/activities/[id]/route.ts +++ b/apps/backend/src/app/api/activities/[id]/route.ts @@ -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({ where: { id: id }, }); diff --git a/apps/backend/src/app/api/activities/[id]/upcoming/route.ts b/apps/backend/src/app/api/activities/[id]/upcoming/route.ts new file mode 100644 index 0000000..c403437 --- /dev/null +++ b/apps/backend/src/app/api/activities/[id]/upcoming/route.ts @@ -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 } + ); + } +} diff --git a/apps/backend/src/app/api/activities/my/route.ts b/apps/backend/src/app/api/activities/my/route.ts index 0e4978a..4b1c12e 100644 --- a/apps/backend/src/app/api/activities/my/route.ts +++ b/apps/backend/src/app/api/activities/my/route.ts @@ -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({ where: { participations: { @@ -55,9 +55,6 @@ export async function GET(req: NextRequest) { userId: userId, }, }, - organizerId: { - not: userId, // Exclude activities organized by user (already in createdActivities) - }, }, include: { venue: true, diff --git a/apps/backend/src/app/api/activities/route.ts b/apps/backend/src/app/api/activities/route.ts index f7a01f2..e9ddd1b 100644 --- a/apps/backend/src/app/api/activities/route.ts +++ b/apps/backend/src/app/api/activities/route.ts @@ -34,9 +34,24 @@ const activitySchema = z.object({ maxAge: z.number().min(6).max(99).default(99), price: z.number().min(0).default(0), 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, { message: "Minimálny vek musí byť menší alebo rovný maximálnemu veku", 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 @@ -63,6 +78,7 @@ export async function GET(request: NextRequest) { ...(maxPrice && { price: { lte: parseFloat(maxPrice) } }), ...(minAge && { maxAge: { gte: parseInt(minAge) } }), // User age >= activity minAge ...(maxAge && { minAge: { lte: parseInt(maxAge) } }), // User age <= activity maxAge + parentActivityId: null, // Only show parent activities, not recurring instances ...(city && { venue: { city: city, @@ -123,13 +139,22 @@ export async function POST(request: NextRequest) { const body = await request.json(); 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({ - data: { - ...validatedData, - date: new Date(validatedData.date), - organizerId: session.user.id, - currentParticipants: 1, - }, + data: activityData, include: { venue: true, organizer: { @@ -148,9 +173,15 @@ export async function POST(request: NextRequest) { userId: session.user.id, activityId: activity.id, 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 }); } catch (error) { 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`); +} diff --git a/apps/backend/src/lib/auth.ts b/apps/backend/src/lib/auth.ts index 07bed7a..e3bea6e 100644 --- a/apps/backend/src/lib/auth.ts +++ b/apps/backend/src/lib/auth.ts @@ -16,12 +16,24 @@ export const auth = betterAuth({ clientSecret: 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: { clientId: process.env.APPLE_CLIENT_ID || "", clientSecret: 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: { expiresIn: 60 * 60 * 24 * 30, // 30 days updateAge: 60 * 60 * 24, // 1 day @@ -30,6 +42,14 @@ export const auth = betterAuth({ generateId: () => crypto.randomUUID(), }, 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; diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 56cfd12..fdfaef8 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@react-google-maps/api": "latest", "@tailwindcss/postcss": "latest", "@tailwindcss/typography": "latest", "better-auth": "latest", diff --git a/apps/frontend/src/app/activities/[id]/page.tsx b/apps/frontend/src/app/activities/[id]/page.tsx index e817b45..33c2e7f 100644 --- a/apps/frontend/src/app/activities/[id]/page.tsx +++ b/apps/frontend/src/app/activities/[id]/page.tsx @@ -25,6 +25,11 @@ interface Activity { minAge: number; maxAge: number; price: number; + isRecurring: boolean; + recurrenceFrequency: string; + recurrenceDays: number[]; + recurrenceEndDate: string | null; + parentActivityId: string | null; venue?: { id: string; name: string; @@ -41,6 +46,7 @@ interface Activity { }; participations: { id: string; + guestCount: number; user: { id: string; name: string; @@ -91,13 +97,50 @@ export default function ActivityDetailPage() { const [error, setError] = useState(""); const [joining, setJoining] = useState(false); const [leaving, setLeaving] = useState(false); + const [deleting, setDeleting] = useState(false); const [currentUserId, setCurrentUserId] = useState(null); + const [guestCount, setGuestCount] = useState(1); + const [addingGuests, setAddingGuests] = useState(false); + const [upcomingInstances, setUpcomingInstances] = useState([]); + 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(() => { fetchActivity(); fetchCurrentUser(); }, [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 () => { try { 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 () => { if (!activity) return; setJoining(true); @@ -140,7 +200,11 @@ export default function ActivityDetailPage() { `${process.env.NEXT_PUBLIC_API_URL}/api/activities/${activity.id}/join`, { method: "POST", + headers: { + "Content-Type": "application/json", + }, credentials: "include", + body: JSON.stringify({ guestCount: 0 }), } ); @@ -160,6 +224,12 @@ export default function ActivityDetailPage() { const handleLeave = async () => { if (!activity) return; + + const confirmLeave = window.confirm( + "Naozaj sa chcete odhlásiť z tejto aktivity?" + ); + if (!confirmLeave) return; + setLeaving(true); try { 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) { return (
@@ -206,7 +351,7 @@ export default function ActivityDetailPage() {

{error || "Aktivita nenájdená"}

- + @@ -235,7 +380,6 @@ export default function ActivityDetailPage() { const isOrganizer = activity.organizer.id === currentUserId; const canJoin = !isParticipating && - !isOrganizer && activity.status === "OPEN" && activity.currentParticipants < activity.maxParticipants; @@ -247,7 +391,7 @@ export default function ActivityDetailPage() {
{/* Back button */} - + @@ -261,9 +405,20 @@ export default function ActivityDetailPage() {

{activity.title}

-

- {sportTypeLabels[activity.sportType] || activity.sportType} -

+
+

+ {sportTypeLabels[activity.sportType] || activity.sportType} +

+ {activity.isRecurring && activity.recurrenceFrequency !== "NONE" && ( + + + + + + Pravidelná aktivita + + )} +
{/* Action buttons */} -
+
{canJoin && ( )} {isOrganizer && ( - - 👤 Organizátor - + <> + + 👤 Organizátor + + + )}
+ + {/* Add guests section for participants */} + {isParticipating && activity.status === "OPEN" && ( +
+
+
+ + 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" + /> +
+ +
+

+ Celkom miest: Vy + {guestCount} {guestCount === 1 ? "hosť" : guestCount < 5 ? "hostia" : "hostí"} = {1 + guestCount} +

+
+ )} @@ -417,6 +615,158 @@ export default function ActivityDetailPage() { + {/* Upcoming instances for recurring activities */} + {((activity.isRecurring && activity.recurrenceFrequency !== "NONE") || activity.parentActivityId) && ( + + setShowUpcoming(!showUpcoming)} + > + +
+ + + + + + + Nadchádzajúce termíny + {upcomingInstances.length > 0 && ( + + {upcomingInstances.length} + + )} +
+ + + +
+
+ {showUpcoming && ( + + {loadingUpcoming ? ( +

Načítavam...

+ ) : upcomingInstances.length === 0 ? ( +

Žiadne nadchádzajúce termíny

+ ) : ( +
+ {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 ( +
+
+
+
+
+ {instanceDate.getDate()} +
+
+ {instanceDate.toLocaleDateString("sk-SK", { month: "short" })} +
+
+
+

+ {formattedDate} o {formattedTime} +

+

+ {instance.currentParticipants}/{instance.maxParticipants} účastníkov +

+
+
+ {isCurrentActivity && ( + + Aktuálny termín + + )} +
+
+ {!isCurrentActivity && ( + + + + )} + {!isParticipatingInInstance && instance.currentParticipants < instance.maxParticipants && ( + + )} + {isParticipatingInInstance && ( + + + + + Prihlásený + + )} +
+
+ ); + })} +
+ )} +
+ )} +
+ )} + {/* Map */} {activity.latitude && activity.longitude && ( @@ -501,9 +851,7 @@ export default function ActivityDetailPage() { {/* Participants list */} - - Účastníci ({activity.participations.length}) - + Prihlásení účastníci
@@ -517,6 +865,11 @@ export default function ActivityDetailPage() {

{participation.user.name} + {participation.guestCount > 0 && ( + + +{participation.guestCount} + + )} {participation.user.id === activity.organizer.id && ( (Organizátor) @@ -534,3 +887,4 @@ export default function ActivityDetailPage() {

); } + diff --git a/apps/frontend/src/app/activities/create/page.tsx b/apps/frontend/src/app/activities/create/page.tsx index 6a9b2c2..16a9776 100644 --- a/apps/frontend/src/app/activities/create/page.tsx +++ b/apps/frontend/src/app/activities/create/page.tsx @@ -53,6 +53,12 @@ export default function CreateActivityPage() { maxAge: 99, price: 0, isPublic: true, + isRecurring: false, + recurrenceFrequency: "NONE" as "NONE" | "DAILY" | "WEEKLY" | "MONTHLY", + recurrenceDays: [] as number[], + recurrenceEndDate: "", + autoJoinAll: false, + autoJoinGuestCount: 0, }); const [location, setLocation] = useState({ @@ -74,6 +80,11 @@ export default function CreateActivityPage() { setError("Minimálny vek nemôže byť väčší ako maximálny vek"); 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); setError(""); @@ -101,6 +112,12 @@ export default function CreateActivityPage() { minAge: formData.minAge, maxAge: formData.maxAge, 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() {
+ {/* Recurring Activity */} +
+ + + {formData.isRecurring && ( +
+ {/* Frequency */} +
+ + +
+ + {/* Days of week for WEEKLY */} + {formData.recurrenceFrequency === "WEEKLY" && ( +
+ +
+ {["Ne", "Po", "Ut", "St", "Št", "Pi", "So"].map((day, index) => { + const isSelected = formData.recurrenceDays.includes(index); + return ( + + ); + })} +
+
+ )} + + {/* End date */} +
+ + +

+ Ak nezadáte, aktivity sa budú generovať na 1 rok dopredu +

+
+ + {/* Auto-join checkbox */} +
+ + + {formData.autoJoinAll && ( +
+ + { + const value = parseInt(e.target.value) || 0; + const maxGuests = formData.maxParticipants - 1; + setFormData((prev) => ({ + ...prev, + autoJoinGuestCount: Math.min(value, maxGuests), + })); + }} + placeholder="0" + /> +

+ Počet ľudí, ktorých berieš so sebou (hosťa) - max {formData.maxParticipants - 1} +

+
+ )} +
+
+ )} +
+ {/* Buttons */}
+ {/* OAuth Divider */} +
+
+
+
+
+
+ + Alebo sa prihláste pomocou + +
+
+
+ + {/* OAuth Buttons */} +
+ + + +
+

Nemáte účet?{' '} diff --git a/apps/frontend/src/app/auth/signup/page.tsx b/apps/frontend/src/app/auth/signup/page.tsx index 764e4bb..5385ae5 100644 --- a/apps/frontend/src/app/auth/signup/page.tsx +++ b/apps/frontend/src/app/auth/signup/page.tsx @@ -228,6 +228,60 @@ export default function SignUpPage() { + {/* OAuth Divider */} +

+
+
+
+
+
+ + Alebo sa zaregistrujte pomocou + +
+
+
+ + {/* OAuth Buttons */} +
+ + + +
+

Už máte účet?{' '} diff --git a/apps/frontend/src/app/my-activities/page.tsx b/apps/frontend/src/app/my-activities/page.tsx new file mode 100644 index 0000000..8061cd5 --- /dev/null +++ b/apps/frontend/src/app/my-activities/page.tsx @@ -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 = { + 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 = { + 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(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 ( + + +

+ {((activity.isRecurring && activity.recurrenceFrequency !== "NONE") || activity.parentActivityId) && ( + + + + + Opakovaná + + )} + + {statusInfo.label} + +
+ + +
+
+
+ {activityDate.getDate()} +
+
+ {activityDate.toLocaleDateString("sk-SK", { month: "short" })} +
+
+
+ {activity.title} +
+ + {sportTypeLabels[activity.sportType] || activity.sportType} + + + {formattedDate} + + {formattedTime} +
+ {isCreator && ( +
+ + 👤 Organizátor + +
+ )} +
+
+
+ + +
+
+ + + + + + {activity.currentParticipants}/{activity.maxParticipants} účastníkov + +
+
+ + + + + {activity.locationName || activity.location} +
+ {activity.price > 0 && ( +
+ + + + + {activity.price} € +
+ )} +
+
+ + + ); + }; + + if (loading) { + return ( +
+
+

Načítavam...

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+
+
+ ); + } + + if (!data) { + return null; + } + + const displayActivities = activeTab === "created" ? data.created : data.joined; + const upcomingCount = activeTab === "created" ? data.stats.upcomingCreated : data.stats.upcomingJoined; + + return ( +
+
+ {/* Header */} +
+

+ Moje aktivity +

+

+ Prehľad všetkých tvojich aktivít +

+
+ + {/* Stats Cards */} +
+ + +
+
+ {data.stats.totalCreated} +
+
+ Vytvorené +
+
+
+
+ + +
+
+ {data.stats.totalJoined} +
+
+ Prihlásené +
+
+
+
+ + +
+
+ {data.stats.upcomingCreated} +
+
+ Nadchádzajúce (vytvorené) +
+
+
+
+ + +
+
+ {data.stats.upcomingJoined} +
+
+ Nadchádzajúce (prihlásené) +
+
+
+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Activities Grid */} + {displayActivities.length === 0 ? ( + + +
+
🏃
+

+ {activeTab === "created" ? "Zatiaľ si nevytvoril žiadne aktivity" : "Zatiaľ si sa neprihlásil na žiadne aktivity"} +

+

+ {activeTab === "created" + ? "Vytvor svoju prvú športovú aktivitu a pozvi ostatných" + : "Prehliadni dostupné aktivity a pripoj sa k niektorej" + } +

+ + + +
+
+
+ ) : ( +
+ {displayActivities.map((activity) => renderActivity(activity, activeTab === "created"))} +
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/app/venues/page.tsx b/apps/frontend/src/app/venues/page.tsx new file mode 100644 index 0000000..194eb36 --- /dev/null +++ b/apps/frontend/src/app/venues/page.tsx @@ -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 = { + 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [selectedActivity, setSelectedActivity] = useState(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 ( +
+
+

Načítavam...

+
+
+ ); + } + + if (loadError) { + return ( +
+
+

Chyba pri načítaní mapy

+
+
+ ); + } + + if (!isLoaded) { + return ( +
+
+

Načítavam mapu...

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+

+ Mapa aktivít +

+

+ {activities.length} aktivít v tvojom okolí +

+
+ + {/* Map */} + + + {/* User location marker */} + {userLocation && ( + + )} + + {/* Activity markers */} + {activities.map((activity) => ( + onMarkerClick(activity)} + icon={{ + url: getMarkerIcon(activity.sportType), + scaledSize: { width: 40, height: 40 } as google.maps.Size, + }} + title={activity.title} + /> + ))} + + {/* Info Window */} + {selectedActivity && ( + +
+

+ {selectedActivity.title} +

+
+

+ {sportTypeLabels[selectedActivity.sportType] || selectedActivity.sportType} +

+

+ {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", + })} +

+

+ {selectedActivity.currentParticipants}/{selectedActivity.maxParticipants} účastníkov +

+

+ 📍 {selectedActivity.locationName || selectedActivity.location} +

+ {selectedActivity.price > 0 && ( +

+ {selectedActivity.price} € +

+ )} +
+ + + +
+
+ )} +
+
+ + {/* Legend */} + + + Legenda + + +
+
+
+ Tvoja poloha +
+ {Object.entries(sportTypeLabels).map(([key, label]) => ( +
+ {label.split(" ")[0]} + {label.split(" ").slice(1).join(" ")} +
+ ))} +
+
+
+
+
+ ); +} + +// 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 = { + 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; +} diff --git a/apps/frontend/src/components/Navigation.tsx b/apps/frontend/src/components/Navigation.tsx index 4115a31..771cfde 100644 --- a/apps/frontend/src/components/Navigation.tsx +++ b/apps/frontend/src/components/Navigation.tsx @@ -16,7 +16,7 @@ export default function Navigation() { const navLinks = [ { href: '/', label: 'Domov' }, { href: '/activities', label: 'Aktivity' }, - { href: '/venues', label: 'Športoviská' }, + { href: '/venues', label: 'Mapa aktivít' }, { href: '/my-activities', label: 'Moje aktivity' }, ]; @@ -89,7 +89,7 @@ export default function Navigation() {