admin/components/events/create/MapLocationPicker.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

259 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import ConsumerModal from '@/components/consumer/ConsumerModal'
import ConsumerPickerTrigger from '@/components/events/create/ConsumerPickerTrigger'
import CloseLinearIcon from '@/components/icons/CloseLinearIcon'
import MapIcon from '@/components/icons/MapIcon'
import { texts } from '@/texts'
import { addToast } from '@/lib/toast'
import { cn } from '@/lib/cn'
import { getPublicMapApiKey } from '@/helpers/mapir'
import { reverseGeocodeMapIr } from '@/services/mapirReverseGeocode'
const MapirLocationPicker = dynamic(() => import('@/components/map/MapirLocationPicker'), { ssr: false })
interface MapLocationPickerProps {
lat: number
lng: number
onChange: (coords: { lat: number; lng: number }) => void
/** پر کردن خودکار آدرس از reverse geocode؛ کاربر همچنان می‌تواند آدرس را ویرایش کند */
onAddressResolved?: (address: string) => void
disabled?: boolean
/** Consumer create uses a bottom sheet; admin/edit keep the inline map. */
mode?: 'inline' | 'modal'
/** Optional address shown on the modal trigger once a place is chosen. */
displayAddress?: string
}
const TEHRAN_LAT = 35.6892
const TEHRAN_LNG = 51.389
function MapCanvas({
centerLat,
centerLng,
markerLat,
markerLng,
disabled,
mapApiKey,
isClient,
className,
mapHeight = 320,
onPick,
}: {
centerLat: number
centerLng: number
markerLat: number | null
markerLng: number | null
disabled?: boolean
mapApiKey: string
isClient: boolean
className?: string
/** ارتفاع صریح پیکسلی — درصد داخل مودال اغلب ۰ می‌شود و مارکر را خراب می‌کند */
mapHeight?: number
onPick: (lat: number, lng: number) => void
}) {
return (
<div
className={cn(
'relative w-full overflow-hidden rounded-2xl border border-default-200',
disabled && 'pointer-events-none opacity-70',
className
)}
style={{ height: mapHeight }}
>
{isClient && mapApiKey ? (
<MapirLocationPicker
scrollZoom
centerLat={centerLat}
centerLng={centerLng}
height={mapHeight}
markerLat={markerLat ?? undefined}
markerLng={markerLng ?? undefined}
zoom={13}
onLocationPick={onPick}
/>
) : isClient && !mapApiKey ? (
<div className="flex h-full w-full items-center justify-center bg-secondary-100 px-3 text-center">
<span className="text-sm text-text-muted">{texts.events.mapKeyMissing}</span>
</div>
) : (
<div className="flex h-full w-full items-center justify-center bg-secondary-100">
<span className="text-sm text-text-muted">{texts.events.mapLoading}</span>
</div>
)}
</div>
)
}
export default function MapLocationPicker({
lat,
lng,
onChange,
onAddressResolved,
disabled,
mode = 'inline',
displayAddress,
}: MapLocationPickerProps) {
const [isClient, setIsClient] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [markerPosition, setMarkerPosition] = useState<[number, number] | null>(null)
const [draftPosition, setDraftPosition] = useState<[number, number] | null>(null)
const mapApiKey = getPublicMapApiKey()
const reverseRequestIdRef = useRef(0)
useEffect(() => {
setIsClient(true)
}, [])
useEffect(() => {
if (Number.isFinite(lat) && Number.isFinite(lng)) {
setMarkerPosition([lat, lng])
}
}, [lat, lng])
const hasValidCoords = Number.isFinite(lat) && Number.isFinite(lng)
const mapCenterLat = hasValidCoords ? lat : TEHRAN_LAT
const mapCenterLng = hasValidCoords ? lng : TEHRAN_LNG
const reverseGeocode = useCallback(
async (pickedLat: number, pickedLng: number) => {
const key = mapApiKey.trim()
if (!key) return
const rid = ++reverseRequestIdRef.current
try {
const address = await reverseGeocodeMapIr(pickedLat, pickedLng, key)
if (rid !== reverseRequestIdRef.current) return
if (address) {
onAddressResolved?.(address)
} else {
addToast({ title: texts.events.mapAddressNotFound, color: 'warning' })
}
} catch (err) {
if (rid !== reverseRequestIdRef.current) return
const message = err instanceof Error ? err.message : texts.validation.map.mapAddressFailed
addToast({ title: message, color: 'danger' })
}
},
[mapApiKey, onAddressResolved]
)
const handleInlineMapClick = useCallback(
async (pickedLat: number, pickedLng: number) => {
if (disabled) return
setMarkerPosition([pickedLat, pickedLng])
onChange({ lat: pickedLat, lng: pickedLng })
await reverseGeocode(pickedLat, pickedLng)
},
[disabled, onChange, reverseGeocode]
)
const openModal = () => {
if (disabled) return
setDraftPosition(markerPosition ?? (hasValidCoords ? [lat, lng] : null))
setIsOpen(true)
}
const acceptModal = async () => {
if (!draftPosition) return
const [pickedLat, pickedLng] = draftPosition
setIsSubmitting(true)
try {
setMarkerPosition(draftPosition)
onChange({ lat: pickedLat, lng: pickedLng })
await reverseGeocode(pickedLat, pickedLng)
setIsOpen(false)
} finally {
setIsSubmitting(false)
}
}
const triggerLabel = displayAddress?.trim()
? displayAddress.trim()
: markerPosition
? texts.events.mapLocationSelected
: texts.events.mapSelectModalTitle
if (mode === 'modal') {
// مرکز نقشه را روی pick عوض نکن — فقط مارکر جابه‌جا شود (jumpTo روی هر کلیک مارکر را از دست می‌دهد/پنهان می‌کند)
return (
<div className="flex flex-col gap-1">
<p className="labelClass flex items-center gap-1 text-sm font-medium text-text-dark">
<MapIcon className="size-4" />
<span>{texts.events.mapTitle}</span>
</p>
<ConsumerPickerTrigger
aria-label={texts.events.mapSelectModalTitle}
disabled={disabled}
onClick={openModal}
>
<span className={cn(!displayAddress?.trim() && !markerPosition && 'text-tertiary-900/70')}>{triggerLabel}</span>
</ConsumerPickerTrigger>
<p className="text-xs text-secondary-20">{disabled ? texts.events.mapLocked : texts.events.mapModalHint}</p>
<ConsumerModal
bodyClassName="overflow-hidden"
cancelIcon={<CloseLinearIcon />}
cancelLabel={texts.common.cancel}
isOpen={isOpen}
isSubmitting={isSubmitting}
submitDisabled={!draftPosition}
submitLabel={texts.common.select}
title={texts.events.mapSelectModalTitle}
onOpenChange={setIsOpen}
onSubmit={() => {
void acceptModal()
}}
>
{isOpen ? (
<MapCanvas
key="map-modal"
centerLat={mapCenterLat}
centerLng={mapCenterLng}
disabled={disabled || isSubmitting}
isClient={isClient}
mapApiKey={mapApiKey}
mapHeight={300}
markerLat={draftPosition?.[0] ?? null}
markerLng={draftPosition?.[1] ?? null}
onPick={(pickedLat, pickedLng) => {
if (disabled || isSubmitting) return
setDraftPosition([pickedLat, pickedLng])
}}
/>
) : null}
</ConsumerModal>
</div>
)
}
return (
<div className="flex flex-col gap-3">
<p className="text-sm font-semibold text-text-dark">{texts.events.mapTitle}</p>
<p className="text-xs text-text-muted">{disabled ? texts.events.mapLocked : texts.events.mapHint}</p>
<MapCanvas
centerLat={mapCenterLat}
centerLng={mapCenterLng}
disabled={disabled}
isClient={isClient}
mapApiKey={mapApiKey}
mapHeight={360}
markerLat={markerPosition?.[0] ?? null}
markerLng={markerPosition?.[1] ?? null}
onPick={handleInlineMapClick}
/>
</div>
)
}