service area picker

This commit is contained in:
hamid zarghami
2025-11-23 09:49:49 +03:30
parent 42960e581a
commit c984e719d7
6 changed files with 318 additions and 13 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ const queryClient = new QueryClient({
// Clear tokens and redirect to login
await removeToken();
await removeRefreshToken();
window.location.href = '/auth/login';
// window.location.href = '/auth/login';
}
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ axiosInstance.interceptors.response.use(
} else {
await removeRefreshToken();
await removeToken();
window.location.href = `/`;
// window.location.href = `/`;
return;
}
} catch (refreshError) {
+16 -3
View File
@@ -16,6 +16,7 @@ import { useFormik } from 'formik'
import * as yup from 'yup'
import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/config/func'
import ServiceAreaPicker from './components/ServiceAreaPicker'
const validationSchema = yup.object({
name: yup.string().required('نام رستوران الزامی است'),
@@ -46,6 +47,7 @@ const GeneralSettings: FC = () => {
longitude: 0,
description: '',
logo: '',
serviceArea: undefined,
},
validationSchema,
onSubmit: (values) => {
@@ -91,13 +93,14 @@ const GeneralSettings: FC = () => {
formik.setValues({
name: restaurantData.name || '',
menuColor: restaurantData.menuColor || '#000000',
phone: restaurantData.phoneNumber || '',
phone: restaurantData.phone || '',
instagram: restaurantData.instagram || '',
address: restaurantData.address || '',
latitude: restaurantData.latitude || 0,
longitude: restaurantData.longitude || 0,
description: restaurantData.description || '',
logo: restaurantData.logo || '',
serviceArea: restaurantData.serviceArea || undefined,
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -207,6 +210,14 @@ const GeneralSettings: FC = () => {
/>
</div>
<div className='mt-8'>
<ServiceAreaPicker
initialPolygon={formik.values.serviceArea}
onPolygonSelect={(polygon) => formik.setFieldValue('serviceArea', polygon)}
/>
</div>
{/* </div> */}
<div className='mt-8 flex justify-end'>
<Button
type='submit'
@@ -223,8 +234,10 @@ const GeneralSettings: FC = () => {
</div>
</Button>
</div>
</form>
</div>
</form >
</div >
<DefaulModal
open={isLocationModalOpen}
@@ -0,0 +1,48 @@
import { type FC } from 'react'
import { Location } from 'iconsax-react'
import Error from '@/components/Error'
import type { GeoJSONPolygon } from '../types/Types'
type ServiceAreaInputProps = {
label?: string
polygon?: GeoJSONPolygon | null
onClick: () => void
error_text?: string
}
const ServiceAreaInput: FC<ServiceAreaInputProps> = ({
label,
polygon,
onClick,
error_text,
}) => {
const getDisplayText = () => {
if (polygon?.coordinates?.[0] && polygon.coordinates[0].length > 0) {
// در GeoJSON Polygon، اولین نقطه در انتها تکرار می‌شود تا بسته شود
// پس باید 1 را کم کنیم تا تعداد واقعی نقاط را نشان دهیم
const pointCount = polygon.coordinates[0].length
const actualPointCount = pointCount > 0 ? pointCount - 1 : 0
return `ناحیه انتخاب شده (${actualPointCount} نقطه)`
}
return 'روی نقشه کلیک کنید تا ناحیه را انتخاب کنید'
}
return (
<div className='mt-8'>
{label && <div className='text-sm mb-1'>{label}</div>}
<div
onClick={onClick}
className='w-full bg-white h-10 text-black px-4 text-xs rounded-xl border border-border flex items-center gap-2 cursor-pointer mt-1'
>
<Location size={20} color='#8C90A3' />
<span className='flex-1 text-right'>{getDisplayText()}</span>
</div>
{error_text && (
<Error errorText={error_text} />
)}
</div>
)
}
export default ServiceAreaInput
@@ -0,0 +1,235 @@
import { type FC, useState, useEffect } from 'react'
import { MapContainer, TileLayer, Polygon, Polyline, useMapEvents, useMap } from 'react-leaflet'
import type { LatLngTuple } from 'leaflet'
import type { LatLng } from 'leaflet'
import type { GeoJSONPolygon, GeoJSONPoint } from '../types/Types'
import { Trash } from 'iconsax-react'
interface ServiceAreaPickerProps {
initialPolygon?: GeoJSONPolygon;
onPolygonSelect: (polygon: GeoJSONPolygon | null) => void
}
const ChangeView = ({ center, zoom }: { center: LatLngTuple; zoom: number }) => {
const map = useMap()
useEffect(() => {
map.setView(center, zoom)
}, [map, center, zoom])
return null
}
const ServiceAreaPicker: FC<ServiceAreaPickerProps> = ({ initialPolygon, onPolygonSelect }) => {
const [points, setPoints] = useState<LatLngTuple[]>([])
const [isDrawing, setIsDrawing] = useState(false)
const [isPolygonClosed, setIsPolygonClosed] = useState(false)
const mapCenter: LatLngTuple = [35.6892, 51.3890] // تهران به عنوان پیش‌فرض
const handleClear = () => {
setPoints([])
setIsDrawing(false)
setIsPolygonClosed(false)
onPolygonSelect(null)
}
// تبدیل GeoJSON Polygon به LatLngTuple[]
const convertGeoJSONToPoints = (polygon: GeoJSONPolygon): LatLngTuple[] => {
if (!polygon.coordinates || polygon.coordinates.length === 0) {
return []
}
// اولین ring از coordinates را بگیر (outer ring)
const ring = polygon.coordinates[0]
// تبدیل [lng, lat] به [lat, lng] و حذف آخرین نقطه اگر تکراری باشد
const convertedPoints: LatLngTuple[] = ring.map((coord) => [coord[1], coord[0]])
// اگر اولین و آخرین نقطه یکسان هستند، آخرین را حذف کن
if (convertedPoints.length > 0 &&
convertedPoints[0][0] === convertedPoints[convertedPoints.length - 1][0] &&
convertedPoints[0][1] === convertedPoints[convertedPoints.length - 1][1]) {
convertedPoints.pop()
}
return convertedPoints
}
// مقداردهی اولیه با initialPolygon
useEffect(() => {
if (initialPolygon) {
const initialPoints = convertGeoJSONToPoints(initialPolygon)
if (initialPoints.length >= 3) {
setPoints(initialPoints)
setIsPolygonClosed(true)
onPolygonSelect(initialPolygon)
} else {
handleClear()
}
} else {
handleClear()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialPolygon])
const convertToGeoJSON = (pts: LatLngTuple[]): GeoJSONPolygon | null => {
if (pts.length < 3) return null
// تبدیل [lat, lng] به [lng, lat] و بستن Polygon با تکرار اولین نقطه
const coords = pts.map((pt) => [pt[1], pt[0]] as GeoJSONPoint)
// در GeoJSON Polygon، اولین نقطه باید در انتها تکرار شود تا بسته شود
if (coords.length > 0 &&
(coords[0][0] !== coords[coords.length - 1][0] ||
coords[0][1] !== coords[coords.length - 1][1])) {
coords.push(coords[0])
}
const coordinates: GeoJSONPoint[][] = [coords]
return {
type: 'Polygon',
coordinates,
}
}
// بررسی فاصله بین دو نقطه (به درجه)
const getDistance = (pt1: LatLngTuple, pt2: LatLngTuple): number => {
const latDiff = pt1[0] - pt2[0]
const lngDiff = pt1[1] - pt2[1]
return Math.sqrt(latDiff * latDiff + lngDiff * lngDiff)
}
const handleMapClick = (e: { latlng: LatLng }) => {
if (!isDrawing && points.length === 0) {
setIsDrawing(true)
}
const newPoint: LatLngTuple = [e.latlng.lat, e.latlng.lng]
// اگر حداقل 3 نقطه داریم و کاربر روی نقطه اول کلیک کرد (یا نزدیک به آن)، Polygon را ببند
if (points.length >= 3) {
const firstPoint = points[0]
const distance = getDistance(newPoint, firstPoint)
// اگر فاصله کمتر از 0.002 درجه باشد (تقریباً 200 متر)، به عنوان کلیک روی نقطه اول در نظر بگیریم
if (distance < 0.002) {
// Polygon را ببند و کامل کن (convertToGeoJSON خودش نقطه اول را اضافه می‌کند)
const geoJsonPolygon = convertToGeoJSON(points)
if (geoJsonPolygon) {
onPolygonSelect(geoJsonPolygon)
setIsDrawing(false)
setIsPolygonClosed(true)
// نقاط را نگه دار تا Polygon کامل نمایش داده شود
return
}
}
}
// اگر Polygon قبلاً بسته شده بود، دوباره شروع کن
if (isPolygonClosed) {
setPoints([])
setIsPolygonClosed(false)
setIsDrawing(true)
onPolygonSelect(null)
}
// نقطه جدید را اضافه کن
const updatedPoints = [...points, newPoint]
setPoints(updatedPoints)
// تا زمانی که Polygon بسته نشده، null بفرست
onPolygonSelect(null)
}
const MapClickHandler = () => {
useMapEvents({
click: handleMapClick,
})
return null
}
const polygonOptions = {
color: '#3B82F6',
fillColor: '#3B82F6',
fillOpacity: 0.2,
weight: 2,
}
const polylineOptions = {
color: '#3B82F6',
weight: 2,
dashArray: '5, 10',
}
// فقط وقتی حداقل 2 نقطه داریم، center را تغییر می‌دهیم
const centerPoint = points.length >= 2
? (() => {
const sum = points.reduce(
(acc, pt) => [acc[0] + pt[0], acc[1] + pt[1]],
[0, 0] as [number, number]
)
return [sum[0] / points.length, sum[1] / points.length] as LatLngTuple
})()
: points.length === 1
? points[0] // با یک نقطه، center همان نقطه است
: mapCenter
return (
<div className="w-full">
<div className="w-full h-[400px] rounded-xl overflow-hidden relative">
<MapContainer
center={centerPoint}
zoom={13}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom={true}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{points.length >= 2 && (
<ChangeView center={centerPoint} zoom={13} />
)}
{points.length === 0 && (
<ChangeView center={mapCenter} zoom={13} />
)}
<MapClickHandler />
{points.length >= 2 && !isPolygonClosed && (
<Polyline
positions={points}
pathOptions={polylineOptions}
/>
)}
{/* فقط وقتی Polygon بسته شده، نمایش بده */}
{points.length >= 3 && isPolygonClosed && (
<Polygon
positions={points}
pathOptions={polygonOptions}
/>
)}
</MapContainer>
</div>
<div className="mt-4 flex flex-col gap-3">
<div className="text-xs text-gray-600 text-center">
{points.length === 0
? 'روی نقشه کلیک کنید تا نقاط ناحیه را انتخاب کنید'
: isPolygonClosed
? `ناحیه کامل شده (${points.length} نقطه)`
: points.length < 3
? `نقاط انتخاب شده: ${points.length} (حداقل 3 نقطه لازم است)`
: `نقاط انتخاب شده: ${points.length} - برای بستن ناحیه روی نقطه اول کلیک کنید`}
</div>
<div className="flex gap-2">
{points.length > 0 && (
<button
type="button"
onClick={handleClear}
className=" w-fit h-10 px-4 text-xs rounded-xl border border-red-500 text-red-500 hover:bg-red-50 transition-colors flex items-center justify-center gap-2"
>
<Trash color='red' size={16} />
<span>پاک کردن ناحیه</span>
</button>
)}
</div>
</div>
</div>
)
}
export default ServiceAreaPicker
+17 -8
View File
@@ -1,5 +1,12 @@
import type { IResponse } from "@/types/response.types";
export type GeoJSONPoint = [number, number];
export type GeoJSONPolygon = {
type: "Polygon";
coordinates: GeoJSONPoint[][];
};
export type UpdateRestaurantType = {
name: string;
menuColor: string;
@@ -10,6 +17,7 @@ export type UpdateRestaurantType = {
latitude: number;
longitude: number;
description: string;
serviceArea?: GeoJSONPolygon;
};
export type Restaurant = {
@@ -19,19 +27,20 @@ export type Restaurant = {
deletedAt: string | null;
name: string;
slug: string;
logo: string | null;
address: string | null;
menuColor: string | null;
latitude: number | null;
longitude: number | null;
serviceArea: unknown | null;
logo: string;
address: string;
menuColor: string;
latitude: number;
longitude: number;
serviceArea: GeoJSONPolygon;
isActive: boolean;
establishedYear: number | null;
phoneNumber: string | null;
instagram: string | null;
phone: string;
instagram: string;
telegram: string | null;
whatsapp: string | null;
description: string | null;
description: string;
seoTitle: string | null;
seoDescription: string | null;
tagNames: unknown | null;