feat: recurring activities, my activities page, map view with markers
- Add recurring activities feature (daily, weekly, monthly) - Auto-join with guest count for recurring series - Parent-child relationship for recurring instances - 'Opakovaná' badge and upcoming instances section - Smart delete logic (promote first child to parent) - My Activities page with created/joined tabs - Map view at /venues with activity markers - Custom sport icons and user location marker - InfoWindow with activity details - Navigation renamed 'Športoviská' to 'Mapa aktivít' - Fix participation tracking for joined activities - Database migrations for recurring and guest count fields
This commit is contained in:
parent
2e63914137
commit
a3f926c44f
114
USER_STORIES.md
114
USER_STORIES.md
@ -103,6 +103,12 @@ aby som našiel spoluhráčov
|
||||
- ✅ Automatické uloženie GPS súradníc a názvu miesta - Jozef Kovalčín
|
||||
- ✅ 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)
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
webpackBuildWorker: true,
|
||||
webpackBuildWorker: false,
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `expiresAt` on the `Account` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Account" DROP COLUMN "expiresAt",
|
||||
ADD COLUMN "accessTokenExpiresAt" TIMESTAMP(3),
|
||||
ADD COLUMN "refreshTokenExpiresAt" TIMESTAMP(3),
|
||||
ADD COLUMN "scope" TEXT;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Participation" ADD COLUMN "guestCount" INTEGER NOT NULL DEFAULT 0;
|
||||
@ -0,0 +1,18 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RecurrenceFrequency" AS ENUM ('NONE', 'DAILY', 'WEEKLY', 'MONTHLY');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN "isRecurring" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "parentActivityId" TEXT,
|
||||
ADD COLUMN "recurrenceDays" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
|
||||
ADD COLUMN "recurrenceEndDate" TIMESTAMP(3),
|
||||
ADD COLUMN "recurrenceFrequency" "RecurrenceFrequency" NOT NULL DEFAULT 'NONE';
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Activity_parentActivityId_idx" ON "Activity"("parentActivityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Activity_isRecurring_idx" ON "Activity"("isRecurring");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_parentActivityId_fkey" FOREIGN KEY ("parentActivityId") REFERENCES "Activity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -70,7 +70,9 @@ model Account {
|
||||
accessToken String? @db.Text
|
||||
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
|
||||
|
||||
@ -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(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
|
||||
87
apps/backend/src/app/api/activities/[id]/upcoming/route.ts
Normal file
87
apps/backend/src/app/api/activities/[id]/upcoming/route.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// GET /api/activities/[id]/upcoming - Get upcoming recurring instances
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// First check if this is a parent recurring activity
|
||||
const activity = await prisma.activity.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
isRecurring: true,
|
||||
recurrenceFrequency: true,
|
||||
parentActivityId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!activity) {
|
||||
return NextResponse.json(
|
||||
{ error: "Aktivita nenájdená" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine the parent ID (either this activity or its parent)
|
||||
const parentId = activity.parentActivityId || id;
|
||||
const isRecurring = activity.parentActivityId
|
||||
? true // If it has a parent, it's a child of recurring activity
|
||||
: activity.isRecurring && activity.recurrenceFrequency !== "NONE";
|
||||
|
||||
// If it's a recurring activity (or child of one), find all instances
|
||||
if (isRecurring) {
|
||||
const upcomingInstances = await prisma.activity.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: parentId }, // Include the parent
|
||||
{ parentActivityId: parentId }, // Include all children
|
||||
],
|
||||
date: {
|
||||
gte: new Date(), // Only future dates
|
||||
},
|
||||
status: "OPEN", // Only open activities
|
||||
},
|
||||
include: {
|
||||
organizer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
participations: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
date: "asc",
|
||||
},
|
||||
take: 10, // Limit to next 10 instances
|
||||
});
|
||||
|
||||
return NextResponse.json(upcomingInstances);
|
||||
}
|
||||
|
||||
// If not recurring, return empty array
|
||||
return NextResponse.json([]);
|
||||
} catch (error) {
|
||||
console.error("Error fetching upcoming instances:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Chyba pri načítaní nadchádzajúcich termínov" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -47,7 +47,7 @@ export async function GET(req: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
// Get activities user joined
|
||||
// Get activities user joined (including those they organized if they also joined)
|
||||
const joinedActivities = await prisma.activity.findMany({
|
||||
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,
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-google-maps/api": "latest",
|
||||
"@tailwindcss/postcss": "latest",
|
||||
"@tailwindcss/typography": "latest",
|
||||
"better-auth": "latest",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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?{' '}
|
||||
|
||||
@ -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)]">
|
||||
Už máte účet?{' '}
|
||||
|
||||
364
apps/frontend/src/app/my-activities/page.tsx
Normal file
364
apps/frontend/src/app/my-activities/page.tsx
Normal file
@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
|
||||
|
||||
interface Activity {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
sportType: string;
|
||||
skillLevel: string;
|
||||
date: string;
|
||||
duration: number;
|
||||
maxParticipants: number;
|
||||
currentParticipants: number;
|
||||
status: string;
|
||||
location: string;
|
||||
locationName: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
gender: string;
|
||||
minAge: number;
|
||||
maxAge: number;
|
||||
price: number;
|
||||
isRecurring: boolean;
|
||||
recurrenceFrequency: string;
|
||||
parentActivityId: string | null;
|
||||
venue?: {
|
||||
id: string;
|
||||
name: string;
|
||||
city: string;
|
||||
address: string;
|
||||
} | null;
|
||||
organizer: {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
};
|
||||
participations: {
|
||||
id: string;
|
||||
guestCount: number;
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
interface MyActivitiesResponse {
|
||||
created: Activity[];
|
||||
joined: Activity[];
|
||||
stats: {
|
||||
totalCreated: number;
|
||||
totalJoined: number;
|
||||
upcomingCreated: number;
|
||||
upcomingJoined: number;
|
||||
};
|
||||
}
|
||||
|
||||
const sportTypeLabels: Record<string, string> = {
|
||||
FOOTBALL: "⚽ Futbal",
|
||||
BASKETBALL: "🏀 Basketbal",
|
||||
TENNIS: "🎾 Tenis",
|
||||
VOLLEYBALL: "🏐 Volejbal",
|
||||
BADMINTON: "🏸 Bedminton",
|
||||
TABLE_TENNIS: "🏓 Stolný tenis",
|
||||
RUNNING: "🏃 Beh",
|
||||
CYCLING: "🚴 Cyklistika",
|
||||
SWIMMING: "🏊 Plávanie",
|
||||
GYM: "💪 Posilňovňa",
|
||||
OTHER: "🎯 Iné",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, { label: string; color: string }> = {
|
||||
OPEN: { label: "Otvorená", color: "bg-green-500" },
|
||||
FULL: { label: "Plná", color: "bg-orange-500" },
|
||||
CANCELLED: { label: "Zrušená", color: "bg-red-500" },
|
||||
COMPLETED: { label: "Ukončená", color: "bg-gray-500" },
|
||||
};
|
||||
|
||||
export default function MyActivitiesPage() {
|
||||
const [data, setData] = useState<MyActivitiesResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"created" | "joined">("created");
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyActivities();
|
||||
}, []);
|
||||
|
||||
const fetchMyActivities = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/activities/my`,
|
||||
{
|
||||
credentials: "include",
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Chyba pri načítaní aktivít");
|
||||
}
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderActivity = (activity: Activity, isCreator: boolean) => {
|
||||
const activityDate = new Date(activity.date);
|
||||
const formattedDate = activityDate.toLocaleDateString("sk-SK", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
const formattedTime = activityDate.toLocaleTimeString("sk-SK", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const isPast = activityDate < new Date();
|
||||
const statusInfo = statusLabels[activity.status];
|
||||
|
||||
const handleClick = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.setItem("activityListSource", "/my-activities");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card key={activity.id} hover className="relative">
|
||||
<Link href={`/activities/${activity.id}`} onClick={handleClick}>
|
||||
<div className="absolute top-4 right-4 flex gap-2">
|
||||
{((activity.isRecurring && activity.recurrenceFrequency !== "NONE") || activity.parentActivityId) && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-purple-500/20 text-purple-400 rounded flex items-center gap-1">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
|
||||
</svg>
|
||||
Opakovaná
|
||||
</span>
|
||||
)}
|
||||
<span className={`px-2 py-1 text-xs font-medium text-white rounded ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CardHeader>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="text-center min-w-[60px]">
|
||||
<div className="text-3xl font-bold text-[color:var(--fluent-accent)]">
|
||||
{activityDate.getDate()}
|
||||
</div>
|
||||
<div className="text-sm text-[color:var(--fluent-text-secondary)] uppercase">
|
||||
{activityDate.toLocaleDateString("sk-SK", { month: "short" })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-xl mb-2">{activity.title}</CardTitle>
|
||||
<div className="flex flex-wrap gap-2 text-sm text-[color:var(--fluent-text-secondary)]">
|
||||
<span className="flex items-center gap-1">
|
||||
{sportTypeLabels[activity.sportType] || activity.sportType}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{formattedDate}</span>
|
||||
<span>•</span>
|
||||
<span>{formattedTime}</span>
|
||||
</div>
|
||||
{isCreator && (
|
||||
<div className="mt-2">
|
||||
<span className="px-2 py-1 text-xs font-medium bg-blue-500/20 text-blue-400 rounded">
|
||||
👤 Organizátor
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
<span>
|
||||
{activity.currentParticipants}/{activity.maxParticipants} účastníkov
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
</svg>
|
||||
<span>{activity.locationName || activity.location}</span>
|
||||
</div>
|
||||
{activity.price > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="1" x2="12" y2="23"></line>
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
|
||||
</svg>
|
||||
<span>{activity.price} €</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<p className="text-center text-[color:var(--fluent-text-secondary)]">Načítavam...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<p className="text-center text-red-500">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayActivities = activeTab === "created" ? data.created : data.joined;
|
||||
const upcomingCount = activeTab === "created" ? data.stats.upcomingCreated : data.stats.upcomingJoined;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-[color:var(--fluent-text)] mb-2">
|
||||
Moje aktivity
|
||||
</h1>
|
||||
<p className="text-[color:var(--fluent-text-secondary)]">
|
||||
Prehľad všetkých tvojich aktivít
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[color:var(--fluent-accent)] mb-1">
|
||||
{data.stats.totalCreated}
|
||||
</div>
|
||||
<div className="text-sm text-[color:var(--fluent-text-secondary)]">
|
||||
Vytvorené
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[color:var(--fluent-accent)] mb-1">
|
||||
{data.stats.totalJoined}
|
||||
</div>
|
||||
<div className="text-sm text-[color:var(--fluent-text-secondary)]">
|
||||
Prihlásené
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-green-500 mb-1">
|
||||
{data.stats.upcomingCreated}
|
||||
</div>
|
||||
<div className="text-sm text-[color:var(--fluent-text-secondary)]">
|
||||
Nadchádzajúce (vytvorené)
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-green-500 mb-1">
|
||||
{data.stats.upcomingJoined}
|
||||
</div>
|
||||
<div className="text-sm text-[color:var(--fluent-text-secondary)]">
|
||||
Nadchádzajúce (prihlásené)
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-4 mb-6 border-b border-[color:var(--fluent-border)]">
|
||||
<button
|
||||
onClick={() => setActiveTab("created")}
|
||||
className={`px-6 py-3 font-medium transition-all ${
|
||||
activeTab === "created"
|
||||
? "text-[color:var(--fluent-accent)] border-b-2 border-[color:var(--fluent-accent)]"
|
||||
: "text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)]"
|
||||
}`}
|
||||
>
|
||||
Vytvorené ({data.stats.totalCreated})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("joined")}
|
||||
className={`px-6 py-3 font-medium transition-all ${
|
||||
activeTab === "joined"
|
||||
? "text-[color:var(--fluent-accent)] border-b-2 border-[color:var(--fluent-accent)]"
|
||||
: "text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)]"
|
||||
}`}
|
||||
>
|
||||
Prihlásené ({data.stats.totalJoined})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Activities Grid */}
|
||||
{displayActivities.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">🏃</div>
|
||||
<h3 className="text-xl font-semibold text-[color:var(--fluent-text)] mb-2">
|
||||
{activeTab === "created" ? "Zatiaľ si nevytvoril žiadne aktivity" : "Zatiaľ si sa neprihlásil na žiadne aktivity"}
|
||||
</h3>
|
||||
<p className="text-[color:var(--fluent-text-secondary)] mb-6">
|
||||
{activeTab === "created"
|
||||
? "Vytvor svoju prvú športovú aktivitu a pozvi ostatných"
|
||||
: "Prehliadni dostupné aktivity a pripoj sa k niektorej"
|
||||
}
|
||||
</p>
|
||||
<Link href={activeTab === "created" ? "/activities/create" : "/activities"}>
|
||||
<Button variant="primary">
|
||||
{activeTab === "created" ? "Vytvoriť aktivitu" : "Prehliadať aktivity"}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{displayActivities.map((activity) => renderActivity(activity, activeTab === "created"))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
319
apps/frontend/src/app/venues/page.tsx
Normal file
319
apps/frontend/src/app/venues/page.tsx
Normal file
@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { GoogleMap, useLoadScript, Marker, InfoWindow } from "@react-google-maps/api";
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
interface Activity {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
sportType: string;
|
||||
date: string;
|
||||
duration: number;
|
||||
maxParticipants: number;
|
||||
currentParticipants: number;
|
||||
location: string;
|
||||
locationName: string | null;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
price: number;
|
||||
organizer: {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const sportTypeLabels: Record<string, string> = {
|
||||
FOOTBALL: "⚽ Futbal",
|
||||
BASKETBALL: "🏀 Basketbal",
|
||||
TENNIS: "🎾 Tenis",
|
||||
VOLLEYBALL: "🏐 Volejbal",
|
||||
BADMINTON: "🏸 Bedminton",
|
||||
TABLE_TENNIS: "🏓 Stolný tenis",
|
||||
RUNNING: "🏃 Beh",
|
||||
CYCLING: "🚴 Cyklistika",
|
||||
SWIMMING: "🏊 Plávanie",
|
||||
GYM: "💪 Posilňovňa",
|
||||
OTHER: "🎯 Iné",
|
||||
};
|
||||
|
||||
const mapContainerStyle = {
|
||||
width: "100%",
|
||||
height: "calc(100vh - 120px)",
|
||||
};
|
||||
|
||||
const defaultCenter = {
|
||||
lat: 48.1486, // Bratislava
|
||||
lng: 17.1077,
|
||||
};
|
||||
|
||||
export default function VenuesPage() {
|
||||
const { isLoaded, loadError } = useLoadScript({
|
||||
googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "",
|
||||
});
|
||||
|
||||
const [activities, setActivities] = useState<Activity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
|
||||
const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [mapCenter, setMapCenter] = useState(defaultCenter);
|
||||
|
||||
useEffect(() => {
|
||||
fetchActivities();
|
||||
getUserLocation();
|
||||
}, []);
|
||||
|
||||
const getUserLocation = () => {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const location = {
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude,
|
||||
};
|
||||
setUserLocation(location);
|
||||
setMapCenter(location);
|
||||
},
|
||||
(error) => {
|
||||
console.error("Error getting location:", error);
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchActivities = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/activities?status=OPEN`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Chyba pri načítaní aktivít");
|
||||
}
|
||||
const data = await response.json();
|
||||
// Filter activities that have coordinates
|
||||
const activitiesWithCoords = data.filter(
|
||||
(a: Activity) => a.latitude != null && a.longitude != null
|
||||
);
|
||||
setActivities(activitiesWithCoords);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onMarkerClick = useCallback((activity: Activity) => {
|
||||
setSelectedActivity(activity);
|
||||
}, []);
|
||||
|
||||
const onInfoWindowClose = useCallback(() => {
|
||||
setSelectedActivity(null);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<p className="text-center text-[color:var(--fluent-text-secondary)]">Načítavam...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<p className="text-center text-red-500">Chyba pri načítaní mapy</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<p className="text-center text-[color:var(--fluent-text-secondary)]">Načítavam mapu...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<p className="text-center text-red-500">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-4xl font-bold text-[color:var(--fluent-text)] mb-2">
|
||||
Mapa aktivít
|
||||
</h1>
|
||||
<p className="text-[color:var(--fluent-text-secondary)]">
|
||||
{activities.length} aktivít v tvojom okolí
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<Card className="overflow-hidden">
|
||||
<GoogleMap
|
||||
mapContainerStyle={mapContainerStyle}
|
||||
center={mapCenter}
|
||||
zoom={13}
|
||||
options={{
|
||||
zoomControl: true,
|
||||
streetViewControl: false,
|
||||
mapTypeControl: false,
|
||||
fullscreenControl: true,
|
||||
}}
|
||||
>
|
||||
{/* User location marker */}
|
||||
{userLocation && (
|
||||
<Marker
|
||||
position={userLocation}
|
||||
icon={{
|
||||
url: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Ccircle cx='10' cy='10' r='8' fill='%234285F4' stroke='white' stroke-width='2'/%3E%3C/svg%3E",
|
||||
scaledSize: { width: 20, height: 20 } as google.maps.Size,
|
||||
anchor: { x: 10, y: 10 } as google.maps.Point,
|
||||
}}
|
||||
title="Tvoja poloha"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Activity markers */}
|
||||
{activities.map((activity) => (
|
||||
<Marker
|
||||
key={activity.id}
|
||||
position={{
|
||||
lat: activity.latitude,
|
||||
lng: activity.longitude,
|
||||
}}
|
||||
onClick={() => onMarkerClick(activity)}
|
||||
icon={{
|
||||
url: getMarkerIcon(activity.sportType),
|
||||
scaledSize: { width: 40, height: 40 } as google.maps.Size,
|
||||
}}
|
||||
title={activity.title}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Info Window */}
|
||||
{selectedActivity && (
|
||||
<InfoWindow
|
||||
position={{
|
||||
lat: selectedActivity.latitude,
|
||||
lng: selectedActivity.longitude,
|
||||
}}
|
||||
onCloseClick={onInfoWindowClose}
|
||||
>
|
||||
<div style={{ padding: '8px', maxWidth: '280px' }}>
|
||||
<h3 style={{ fontWeight: 'bold', fontSize: '18px', marginBottom: '8px', color: '#000' }}>
|
||||
{selectedActivity.title}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '14px', marginBottom: '12px', color: '#333' }}>
|
||||
<p style={{ display: 'flex', alignItems: 'center', gap: '8px', margin: 0 }}>
|
||||
{sportTypeLabels[selectedActivity.sportType] || selectedActivity.sportType}
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>
|
||||
{new Date(selectedActivity.date).toLocaleDateString("sk-SK", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}{" "}
|
||||
o{" "}
|
||||
{new Date(selectedActivity.date).toLocaleTimeString("sk-SK", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>
|
||||
{selectedActivity.currentParticipants}/{selectedActivity.maxParticipants} účastníkov
|
||||
</p>
|
||||
<p style={{ color: '#666', margin: 0 }}>
|
||||
📍 {selectedActivity.locationName || selectedActivity.location}
|
||||
</p>
|
||||
{selectedActivity.price > 0 && (
|
||||
<p style={{ fontWeight: '500', color: '#16a34a', margin: 0 }}>
|
||||
{selectedActivity.price} €
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Link href={`/activities/${selectedActivity.id}`}>
|
||||
<button style={{
|
||||
width: '100%',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#2563eb',
|
||||
color: 'white',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '500',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
Zobraziť detail
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</InfoWindow>
|
||||
)}
|
||||
</GoogleMap>
|
||||
</Card>
|
||||
|
||||
{/* Legend */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Legenda</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 border-2 border-white"></div>
|
||||
<span className="text-sm">Tvoja poloha</span>
|
||||
</div>
|
||||
{Object.entries(sportTypeLabels).map(([key, label]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span className="text-2xl">{label.split(" ")[0]}</span>
|
||||
<span className="text-sm">{label.split(" ").slice(1).join(" ")}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to get marker icon based on sport type
|
||||
function getMarkerIcon(sportType: string): string {
|
||||
// Using emoji as markers - you can replace with custom icons
|
||||
const icons: Record<string, string> = {
|
||||
FOOTBALL: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E⚽%3C/text%3E%3C/svg%3E",
|
||||
BASKETBALL: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🏀%3C/text%3E%3C/svg%3E",
|
||||
TENNIS: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🎾%3C/text%3E%3C/svg%3E",
|
||||
VOLLEYBALL: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🏐%3C/text%3E%3C/svg%3E",
|
||||
BADMINTON: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🏸%3C/text%3E%3C/svg%3E",
|
||||
TABLE_TENNIS: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🏓%3C/text%3E%3C/svg%3E",
|
||||
RUNNING: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🏃%3C/text%3E%3C/svg%3E",
|
||||
CYCLING: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🚴%3C/text%3E%3C/svg%3E",
|
||||
SWIMMING: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🏊%3C/text%3E%3C/svg%3E",
|
||||
GYM: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E💪%3C/text%3E%3C/svg%3E",
|
||||
OTHER: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Ctext x='50%25' y='50%25' font-size='30' text-anchor='middle' dominant-baseline='central'%3E🎯%3C/text%3E%3C/svg%3E",
|
||||
};
|
||||
|
||||
return icons[sportType] || icons.OTHER;
|
||||
}
|
||||
@ -16,7 +16,7 @@ export default function Navigation() {
|
||||
const navLinks = [
|
||||
{ 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)',
|
||||
|
||||
@ -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' ? (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user