Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
259 lines
8.1 KiB
TypeScript
259 lines
8.1 KiB
TypeScript
'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>
|
||
)
|
||
}
|