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:
Jozef Kovalčín 2025-11-13 17:56:15 +01:00
parent 2e63914137
commit a3f926c44f
24 changed files with 1874 additions and 81 deletions

View File

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

View File

@ -2,7 +2,7 @@
const nextConfig = {
reactStrictMode: true,
experimental: {
webpackBuildWorker: true,
webpackBuildWorker: false,
},
async headers() {
return [

View File

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

View File

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

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Participation" ADD COLUMN "guestCount" INTEGER NOT NULL DEFAULT 0;

View File

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

View File

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

View File

@ -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: "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({
data: {
// Update participation with new guest count
await prisma.participation.update({
where: {
userId_activityId: {
userId: session.user.id,
activityId: id,
status: "CONFIRMED",
},
},
data: {
guestCount: guestCount,
},
});
// Update activity participant count
const updatedActivity = await prisma.activity.update({
await prisma.activity.update({
where: { id: id },
data: {
currentParticipants: {
increment: 1,
},
status:
activity.currentParticipants + 1 >= activity.maxParticipants
? "FULL"
: "OPEN",
currentParticipants: newTotal,
status: newTotal >= activity.maxParticipants ? "FULL" : "OPEN",
},
});
return NextResponse.json({ participation, activity: updatedActivity });
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: `K dispozícii je len ${availableSpots} voľných miest` },
{ status: 400 }
);
}
// Create new participation
await prisma.participation.create({
data: {
userId: session.user.id,
activityId: id,
status: "CONFIRMED",
guestCount: guestCount,
},
});
// Update activity participant count (user + guests)
const newParticipantCount = activity.currentParticipants + totalNeeded;
const updatedActivity = await prisma.activity.update({
where: { id: id },
data: {
currentParticipants: newParticipantCount,
status: newParticipantCount >= activity.maxParticipants ? "FULL" : "OPEN",
},
});
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(
);
}
}

View File

@ -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 },
});

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

View File

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

View File

@ -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);
const activity = await prisma.activity.create({
data: {
...validatedData,
// 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,
},
currentParticipants: 1 + (autoJoinGuestCount || 0), // 1 for organizer + guests
recurrenceEndDate: validatedData.recurrenceEndDate
? new Date(validatedData.recurrenceEndDate)
: undefined,
};
const activity = await prisma.activity.create({
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`);
}

View File

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

View File

@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"@react-google-maps/api": "latest",
"@tailwindcss/postcss": "latest",
"@tailwindcss/typography": "latest",
"better-auth": "latest",

View File

@ -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<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(() => {
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 (
<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">
{error || "Aktivita nenájdená"}
</h3>
<Link href="/activities">
<Link href={returnUrl}>
<Button variant="primary" className="mt-4">
Späť na zoznam
</Button>
@ -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() {
<div className="container mx-auto px-4 py-8">
<div className="max-w-5xl mx-auto">
{/* Back button */}
<Link href="/activities">
<Link href={returnUrl}>
<Button variant="secondary" className="mb-6">
Späť na zoznam
</Button>
@ -261,9 +405,20 @@ export default function ActivityDetailPage() {
<h1 className="text-4xl font-bold text-[color:var(--fluent-text)] mb-2">
{activity.title}
</h1>
<div className="flex items-center gap-3">
<p className="text-xl text-[color:var(--fluent-text-secondary)]">
{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>
<span
className={`px-4 py-2 text-sm font-medium text-white rounded-full ${statusInfo.color}`}
@ -273,7 +428,7 @@ export default function ActivityDetailPage() {
</div>
{/* Action buttons */}
<div className="flex gap-3 mt-6">
<div className="flex flex-wrap gap-3 mt-6">
{canJoin && (
<Button
variant="primary"
@ -283,21 +438,64 @@ export default function ActivityDetailPage() {
{joining ? "Prihlasovanie..." : "✓ Prihlásiť sa"}
</Button>
)}
{isParticipating && !isOrganizer && (
{isParticipating && (
<Button
variant="secondary"
onClick={handleLeave}
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"}
</Button>
)}
{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>
<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>
{/* 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>
</Card>
@ -417,6 +615,158 @@ export default function ActivityDetailPage() {
</CardContent>
</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 */}
{activity.latitude && activity.longitude && (
<Card>
@ -501,9 +851,7 @@ export default function ActivityDetailPage() {
{/* Participants list */}
<Card>
<CardHeader>
<CardTitle>
Účastníci ({activity.participations.length})
</CardTitle>
<CardTitle>Prihlásení účastníci</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
@ -517,6 +865,11 @@ export default function ActivityDetailPage() {
</div>
<p className="text-sm text-[color:var(--fluent-text)]">
{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 && (
<span className="ml-2 text-xs text-[color:var(--fluent-accent)]">
(Organizátor)
@ -534,3 +887,4 @@ export default function ActivityDetailPage() {
</div>
);
}

View File

@ -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({
@ -75,6 +81,11 @@ export default function CreateActivityPage() {
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() {
</label>
</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 */}
<div className="flex gap-4 justify-end">
<Button

View File

@ -20,6 +20,9 @@ interface Activity {
locationName: string | null;
latitude: number | null;
longitude: number | null;
isRecurring: boolean;
recurrenceFrequency: string;
parentActivityId: string | null;
venue?: {
id: string;
name: string;
@ -77,16 +80,33 @@ function ActivityCard({ activity }: { activity: Activity }) {
const freeSpots = activity.maxParticipants - activity.currentParticipants;
const statusInfo = statusLabels[activity.status] || statusLabels.OPEN;
const handleClick = () => {
if (typeof window !== "undefined") {
sessionStorage.setItem("activityListSource", "/activities");
}
};
return (
<Link href={`/activities/${activity.id}`}>
<Link href={`/activities/${activity.id}`} onClick={handleClick}>
<Card hover className="h-full">
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex justify-between items-start mb-4">
<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">
<h3 className="text-xl font-bold text-[color:var(--fluent-text)]">
{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)]">
{sportTypeLabels[activity.sportType] || activity.sportType}
</p>

View File

@ -142,6 +142,60 @@ export default function SignInPage() {
</Button>
</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">
<p className="text-base text-[color:var(--fluent-text-secondary)]">
Nemáte účet?{' '}

View File

@ -228,6 +228,60 @@ export default function SignUpPage() {
</Button>
</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">
<p className="text-base text-[color:var(--fluent-text-secondary)]">
máte účet?{' '}

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

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

View File

@ -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() {
<div className="relative" ref={userMenuRef}>
<button
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)' }}
>
<div className="w-10 h-10 gradient-primary rounded-full flex items-center justify-center">
@ -138,7 +138,7 @@ export default function Navigation() {
</Link>
<button
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">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -184,7 +184,7 @@ export default function Navigation() {
<ThemeToggle />
<button
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">
<span
@ -283,7 +283,7 @@ export default function Navigation() {
</Link>
<button
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={{
boxShadow: 'var(--shadow-sm)',
backdropFilter: 'blur(20px)',

View File

@ -21,7 +21,7 @@ export function ThemeToggle() {
return (
<button
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"
>
{theme === 'light' ? (

View File

@ -34,14 +34,16 @@ export const Card: React.FC<CardProps> = ({
interface CardHeaderProps {
children: React.ReactNode;
className?: string;
onClick?: () => void;
}
export const CardHeader: React.FC<CardHeaderProps> = ({
children,
className = ''
className = '',
onClick
}) => {
return (
<div className={`mb-5 ${className}`}>
<div className={`mb-5 ${className}`} onClick={onClick}>
{children}
</div>
);