admin/components/map/MapirLocationPicker.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

132 lines
4.0 KiB
TypeScript

'use client'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import MarkerIcon from '@/components/icons/MarkerIcon'
import { getMapirMapComponent, MAPIR_STYLE_URL } from '@/components/map/mapirMapFactory'
import { getPublicMapApiKey, toMapirLngLat } from '@/helpers/mapir'
interface MapirLocationPickerProps {
centerLat: number
centerLng: number
markerLat?: number | null
markerLng?: number | null
zoom?: number
scrollZoom?: boolean
height?: number | string
onLocationPick: (lat: number, lng: number) => void
}
interface MapLike {
resize: () => void
jumpTo: (opts: { center: [number, number] }) => void
}
const DEFAULT_ZOOM = 13
const MapirLocationPicker = ({
centerLat,
centerLng,
markerLat,
markerLng,
zoom = DEFAULT_ZOOM,
scrollZoom = true,
height = '100%',
onLocationPick,
}: MapirLocationPickerProps) => {
const apiKey = getPublicMapApiKey()
const Map = useMemo(() => {
const k = apiKey.trim()
if (!k) return null
return getMapirMapComponent(k)
}, [apiKey])
// اگر مارکر انتخاب شده، همان نقطه مرکز نقشه باشد تا پین وسط روی موقعیت درست بنشیند
const viewLat = markerLat != null && Number.isFinite(markerLat) ? markerLat : centerLat
const viewLng = markerLng != null && Number.isFinite(markerLng) ? markerLng : centerLng
const center = useMemo(() => toMapirLngLat(viewLat, viewLng), [viewLat, viewLng])
const zoomArr = useMemo(() => [zoom] as [number], [zoom])
const mapRef = useRef<MapLike | null>(null)
const onPickRef = useRef(onLocationPick)
const rootRef = useRef<HTMLDivElement | null>(null)
const hasSelection = markerLat != null && markerLng != null && Number.isFinite(markerLat) && Number.isFinite(markerLng)
useEffect(() => {
onPickRef.current = onLocationPick
}, [onLocationPick])
useEffect(() => {
const root = rootRef.current
if (!root || typeof ResizeObserver === 'undefined') return
const resize = () => {
mapRef.current?.resize()
}
const observer = new ResizeObserver(resize)
observer.observe(root)
const timer = window.setTimeout(resize, 320)
return () => {
observer.disconnect()
window.clearTimeout(timer)
}
}, [])
const onStyleLoad = useCallback((map: MapLike) => {
mapRef.current = map
map.resize()
}, [])
// mapir-react listener اول onClick را نگه می‌دارد؛ pick را از ref می‌خوانیم
const onClick = useCallback((_map: MapLike, evt: { lngLat?: { lat: number; lng: number } }) => {
const ll = evt?.lngLat
if (!ll) return
// پین CSS همیشه وسط است؛ با jumpTo نقطهٔ کلیک زیر پین می‌آید
mapRef.current?.jumpTo({ center: [ll.lng, ll.lat] })
onPickRef.current(ll.lat, ll.lng)
}, [])
if (!Map) {
return null
}
// Map.ir Map is memoized/cached by API key in getMapirMapComponent — not recreated each render.
/* eslint-disable react-hooks/static-components -- Map component comes from mapir setToken cache */
return (
<div
ref={rootRef}
className="relative h-full w-full min-h-[240px]"
data-testid="mapir-location-picker"
>
<Map
center={center}
containerStyle={{ height, position: 'relative', width: '100%' }}
maxZoom={22}
minZoom={0}
movingMethod="jumpTo"
scrollZoom={scrollZoom}
style={MAPIR_STYLE_URL}
zoom={zoomArr}
onClick={onClick}
onStyleLoad={onStyleLoad}
/>
{/* پین ثابت وسط نقشه — بدون وابستگی به Mapir.Marker که در مودال نامرئی می‌ماند */}
<div
aria-hidden
className={`pointer-events-none absolute inset-0 z-20 flex items-center justify-center ${hasSelection ? 'opacity-100' : 'opacity-70'}`}
>
<MarkerIcon className="size-9 -translate-y-1/2 text-primary drop-shadow-md" />
</div>
</div>
)
/* eslint-enable react-hooks/static-components */
}
export default MapirLocationPicker