SportBuddy/apps/frontend/src/app/venues/page.tsx
Jozef Kovalčín a3f926c44f 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
2025-11-13 17:58:34 +01:00

320 lines
12 KiB
TypeScript

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