service area picker
This commit is contained in:
+1
-1
@@ -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
@@ -64,7 +64,7 @@ axiosInstance.interceptors.response.use(
|
||||
} else {
|
||||
await removeRefreshToken();
|
||||
await removeToken();
|
||||
window.location.href = `/`;
|
||||
// window.location.href = `/`;
|
||||
return;
|
||||
}
|
||||
} catch (refreshError) {
|
||||
|
||||
@@ -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='© <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
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user