/opt/canhelp/apps/web/src/app/tasks
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' : 'Άνοιγμα'}
)}
)
}