/opt/canhelp/apps/web/src/app/tasks
NameSizeModeActions
new/-0755rm
[id]/-0755rm
page.tsx101240644editdlrm
TaskFiltersClient.tsx76440644editdlrm
TaskFiltersForm.tsx4930644editdlrm
TaskListClient.tsx57510644editdlrm
TaskMapClient.tsx92400644editdlrm
TaskMapDynamic.tsx6580644editdlrm
TaskModeToggle.tsx15890644editdlrm
TasksAuthGuard.tsx650644editdlrm
TaskViewToggle.tsx21320644editdlrm
Edit: /opt/canhelp/apps/web/src/app/tasks/TaskMapClient.tsx (9240B)
'use client' // Static imports are safe — this file is loaded with ssr: false import L from 'leaflet' import 'leaflet/dist/leaflet.css' import 'leaflet.markercluster' import 'leaflet.markercluster/dist/MarkerCluster.css' import 'leaflet.markercluster/dist/MarkerCluster.Default.css' import { useEffect, useRef, useState, useCallback } from 'react' import Link from 'next/link' import { useLocale } from '@/context/locale' import { taskTitle } from '@/lib/taskLocale' const BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000' // ── City coordinates (same as mobile) ───────────────────────────────────── const CITY_COORDS: Record = { 'athens': [37.9838, 23.7275], 'thessaloniki': [40.6401, 22.9444], 'patras': [38.2466, 21.7346], 'heraklion': [35.3387, 25.1442], 'larissa': [39.6386, 22.4191], 'volos': [39.3666, 22.9427], 'ioannina': [39.6650, 20.8537], 'chania': [35.5138, 24.0180], 'rhodes': [36.4341, 28.2176], 'kavala': [40.9396, 24.4040], 'athens-center': [37.9838, 23.7275], 'kolonaki': [37.9784, 23.7447], 'exarchia': [37.9869, 23.7339], 'monastiraki': [37.9755, 23.7235], 'piraeus': [37.9497, 23.6458], 'glyfada': [37.8636, 23.7523], 'kifissia': [38.0736, 23.8139], 'marousi': [38.0500, 23.8063], 'chalandri': [38.0215, 23.8008], 'holargos': [37.9980, 23.7946], 'nea-smyrni': [37.9458, 23.7176], 'kallithea': [37.9568, 23.7063], 'thessaloniki-center': [40.6401, 22.9444], 'kalamaria': [40.5832, 22.9622], 'stavroupoli': [40.6703, 22.9180], } const DEFAULT_CENTER: [number, number] = [38.0, 23.7] function coordsForSlug(slug?: string): [number, number] { if (!slug) return DEFAULT_CENTER if (CITY_COORDS[slug]) return CITY_COORDS[slug] for (const [key, val] of Object.entries(CITY_COORDS)) { if (key.startsWith(slug) || slug.startsWith(key)) return val } return DEFAULT_CENTER } function scatterCoords(base: [number, number], index: number): [number, number] { // Simple deterministic scatter const seed = index * 31337 const dLat = ((seed % 100) / 100 - 0.5) * 0.006 const dLng = (((seed * 7) % 100) / 100 - 0.5) * 0.008 return [base[0] + dLat, base[1] + dLng] } // ── Props ───────────────────────────────────────────────────────────────── interface Props { filterParams: Record catMap: Record locMap: Record mode?: 'all' | 'for_me' fullscreen?: boolean } // ── Component ────────────────────────────────────────────────────────────── export function TaskMapClient({ filterParams, catMap, locMap, mode = 'all', fullscreen = false }: Props) { const { locale } = useLocale() const mapContainerRef = useRef(null) const mapRef = useRef(null) const clusterRef = useRef(null) const [tasks, setTasks] = useState([]) const [loading, setLoading] = useState(true) const [selectedTask, setSelectedTask] = useState(null) // Fetch all tasks (no pagination for map) const fetchTasks = useCallback(async () => { setLoading(true) try { const params = new URLSearchParams({ ...filterParams, limit: '200' }) const endpoint = mode === 'for_me' ? '/tasks/for-me' : '/tasks' const res = await fetch(`${BASE}/api${endpoint}?${params}`, { credentials: 'include' }) const json = await res.json() setTasks(json.data ?? []) } catch { setTasks([]) } finally { setLoading(false) } }, [filterParams, mode]) useEffect(() => { fetchTasks() }, [fetchTasks]) // Init map once on mount useEffect(() => { if (!mapContainerRef.current || mapRef.current) return const map = L.map(mapContainerRef.current, { center: DEFAULT_CENTER, zoom: 7, zoomControl: true, }) L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19, }).addTo(map) mapRef.current = map return () => { map.remove() mapRef.current = null } }, []) // Rebuild markers whenever tasks or loading state changes useEffect(() => { const map = mapRef.current if (!map || loading) return if (clusterRef.current) { map.removeLayer(clusterRef.current) clusterRef.current = null } const clusterGroup = (L as any).markerClusterGroup({ maxClusterRadius: 60, disableClusteringAtZoom: 13, iconCreateFunction: (cluster: any) => { const count = cluster.getChildCount() return L.divIcon({ html: `
${count}
`, className: '', iconSize: [44, 44], iconAnchor: [22, 22], }) }, }) tasks.forEach((row, i) => { const task = row.task || row const base = coordsForSlug(task.location) const [lat, lng] = scatterCoords(base, i) const budget = task.budget const label = budget ? `€${budget}` : '🤝' const width = budget ? label.length * 9 + 20 : 36 const icon = L.divIcon({ html: `
${label}
`, className: '', iconSize: [width, 30], iconAnchor: [width / 2, 15], }) const marker = L.marker([lat, lng], { icon }) marker.on('click', () => setSelectedTask(task)) clusterGroup.addLayer(marker) }) map.addLayer(clusterGroup) clusterRef.current = clusterGroup }, [tasks, loading]) return (
{/* Map container */}
{/* Loading overlay */} {loading && (
)} {/* Task count badge */} {!loading && (
{tasks.length} {locale === 'ru' ? 'заказов' : locale === 'en' ? 'tasks' : 'εργασίες'}
)} {/* Selected task panel */} {selectedTask && (
{selectedTask.category && ( {catMap[selectedTask.category] ?? selectedTask.category} )} {selectedTask.location && ( 📍 {locMap[selectedTask.location] ?? selectedTask.location} )}

{taskTitle(selectedTask, locale)}

{selectedTask.budget && (

€{selectedTask.budget}

)}
{locale === 'ru' ? 'Открыть' : locale === 'en' ? 'View' : 'Άνοιγμα'}
)}
) }