service area picker
This commit is contained in:
+1
-1
@@ -82,7 +82,7 @@ const queryClient = new QueryClient({
|
|||||||
// Clear tokens and redirect to login
|
// Clear tokens and redirect to login
|
||||||
await removeToken();
|
await removeToken();
|
||||||
await removeRefreshToken();
|
await removeRefreshToken();
|
||||||
window.location.href = '/auth/login';
|
// window.location.href = '/auth/login';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -64,7 +64,7 @@ axiosInstance.interceptors.response.use(
|
|||||||
} else {
|
} else {
|
||||||
await removeRefreshToken();
|
await removeRefreshToken();
|
||||||
await removeToken();
|
await removeToken();
|
||||||
window.location.href = `/`;
|
// window.location.href = `/`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useFormik } from 'formik'
|
|||||||
import * as yup from 'yup'
|
import * as yup from 'yup'
|
||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
import { extractErrorMessage } from '@/config/func'
|
import { extractErrorMessage } from '@/config/func'
|
||||||
|
import ServiceAreaPicker from './components/ServiceAreaPicker'
|
||||||
|
|
||||||
const validationSchema = yup.object({
|
const validationSchema = yup.object({
|
||||||
name: yup.string().required('نام رستوران الزامی است'),
|
name: yup.string().required('نام رستوران الزامی است'),
|
||||||
@@ -46,6 +47,7 @@ const GeneralSettings: FC = () => {
|
|||||||
longitude: 0,
|
longitude: 0,
|
||||||
description: '',
|
description: '',
|
||||||
logo: '',
|
logo: '',
|
||||||
|
serviceArea: undefined,
|
||||||
},
|
},
|
||||||
validationSchema,
|
validationSchema,
|
||||||
onSubmit: (values) => {
|
onSubmit: (values) => {
|
||||||
@@ -91,13 +93,14 @@ const GeneralSettings: FC = () => {
|
|||||||
formik.setValues({
|
formik.setValues({
|
||||||
name: restaurantData.name || '',
|
name: restaurantData.name || '',
|
||||||
menuColor: restaurantData.menuColor || '#000000',
|
menuColor: restaurantData.menuColor || '#000000',
|
||||||
phone: restaurantData.phoneNumber || '',
|
phone: restaurantData.phone || '',
|
||||||
instagram: restaurantData.instagram || '',
|
instagram: restaurantData.instagram || '',
|
||||||
address: restaurantData.address || '',
|
address: restaurantData.address || '',
|
||||||
latitude: restaurantData.latitude || 0,
|
latitude: restaurantData.latitude || 0,
|
||||||
longitude: restaurantData.longitude || 0,
|
longitude: restaurantData.longitude || 0,
|
||||||
description: restaurantData.description || '',
|
description: restaurantData.description || '',
|
||||||
logo: restaurantData.logo || '',
|
logo: restaurantData.logo || '',
|
||||||
|
serviceArea: restaurantData.serviceArea || undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -207,6 +210,14 @@ const GeneralSettings: FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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'>
|
<div className='mt-8 flex justify-end'>
|
||||||
<Button
|
<Button
|
||||||
type='submit'
|
type='submit'
|
||||||
@@ -223,8 +234,10 @@ const GeneralSettings: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form >
|
||||||
</div>
|
</div >
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<DefaulModal
|
<DefaulModal
|
||||||
open={isLocationModalOpen}
|
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";
|
import type { IResponse } from "@/types/response.types";
|
||||||
|
|
||||||
|
export type GeoJSONPoint = [number, number];
|
||||||
|
|
||||||
|
export type GeoJSONPolygon = {
|
||||||
|
type: "Polygon";
|
||||||
|
coordinates: GeoJSONPoint[][];
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdateRestaurantType = {
|
export type UpdateRestaurantType = {
|
||||||
name: string;
|
name: string;
|
||||||
menuColor: string;
|
menuColor: string;
|
||||||
@@ -10,6 +17,7 @@ export type UpdateRestaurantType = {
|
|||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
description: string;
|
description: string;
|
||||||
|
serviceArea?: GeoJSONPolygon;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Restaurant = {
|
export type Restaurant = {
|
||||||
@@ -19,19 +27,20 @@ export type Restaurant = {
|
|||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
logo: string | null;
|
logo: string;
|
||||||
address: string | null;
|
address: string;
|
||||||
menuColor: string | null;
|
menuColor: string;
|
||||||
latitude: number | null;
|
latitude: number;
|
||||||
longitude: number | null;
|
longitude: number;
|
||||||
serviceArea: unknown | null;
|
serviceArea: GeoJSONPolygon;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
establishedYear: number | null;
|
establishedYear: number | null;
|
||||||
phoneNumber: string | null;
|
phoneNumber: string | null;
|
||||||
instagram: string | null;
|
phone: string;
|
||||||
|
instagram: string;
|
||||||
telegram: string | null;
|
telegram: string | null;
|
||||||
whatsapp: string | null;
|
whatsapp: string | null;
|
||||||
description: string | null;
|
description: string;
|
||||||
seoTitle: string | null;
|
seoTitle: string | null;
|
||||||
seoDescription: string | null;
|
seoDescription: string | null;
|
||||||
tagNames: unknown | null;
|
tagNames: unknown | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user