216 lines
7.0 KiB
TypeScript
216 lines
7.0 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { MapContainer, TileLayer, Marker, Circle, useMapEvents, useMap } from 'react-leaflet';
|
|
import 'leaflet/dist/leaflet.css';
|
|
import L from 'leaflet';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { LocateFixed } from 'lucide-react';
|
|
|
|
// Fix for default marker icon in leaflet
|
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
|
L.Icon.Default.mergeOptions({
|
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
|
});
|
|
|
|
interface LocationPickerProps {
|
|
latitude: number | null;
|
|
longitude: number | null;
|
|
radius: number | null;
|
|
onChange: (lat: number | null, lng: number | null, radius: number | null) => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
function LocationMarker({ position, radius, onPositionChange, onRadiusChange }: any) {
|
|
const [handlePosition, setHandlePosition] = useState<L.LatLng | null>(null);
|
|
|
|
useMapEvents({
|
|
click(e) {
|
|
if (onPositionChange) {
|
|
onPositionChange(e.latlng.lat, e.latlng.lng);
|
|
}
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (position && radius) {
|
|
const center = L.latLng(position);
|
|
// approximate 1 meter in degrees (very rough, better to use leaflet's projection, but this is just for UI handle placement)
|
|
// Actually, a simple offset: 1 degree lat is ~111km, so 1 meter is ~ 1/111000 degrees.
|
|
const offsetLng = radius / (111320 * Math.cos(center.lat * Math.PI / 180));
|
|
setHandlePosition(L.latLng(center.lat, center.lng + offsetLng));
|
|
} else {
|
|
setHandlePosition(null);
|
|
}
|
|
}, [position, radius]);
|
|
|
|
const handleDrag = (e: any) => {
|
|
if (position && onRadiusChange) {
|
|
const center = L.latLng(position);
|
|
const newDistance = center.distanceTo(e.latlng);
|
|
onRadiusChange(Math.round(newDistance));
|
|
}
|
|
};
|
|
|
|
return position === null ? null : (
|
|
<>
|
|
<Marker position={position} />
|
|
<Circle
|
|
center={position}
|
|
radius={radius}
|
|
pathOptions={{ color: 'blue', fillColor: 'blue', fillOpacity: 0.2 }}
|
|
/>
|
|
{handlePosition && (
|
|
<Marker
|
|
position={handlePosition}
|
|
draggable={true}
|
|
eventHandlers={{
|
|
drag: handleDrag,
|
|
dragend: handleDrag
|
|
}}
|
|
icon={L.divIcon({
|
|
className: 'custom-handle-icon',
|
|
html: '<div style="width: 12px; height: 12px; background: blue; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 4px rgba(0,0,0,0.5);"></div>',
|
|
iconSize: [12, 12],
|
|
iconAnchor: [6, 6]
|
|
})}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function LocationPicker({ latitude, longitude, radius, onChange, disabled }: LocationPickerProps) {
|
|
const mapRef = React.useRef<L.Map>(null);
|
|
const [locating, setLocating] = useState(false);
|
|
|
|
const [position, setPosition] = useState<L.LatLngExpression | null>(
|
|
latitude && longitude ? [latitude, longitude] : null
|
|
);
|
|
|
|
const [localRadius, setLocalRadius] = useState(radius || 100);
|
|
|
|
useEffect(() => {
|
|
if (latitude && longitude) {
|
|
setPosition([latitude, longitude]);
|
|
}
|
|
}, [latitude, longitude]);
|
|
|
|
useEffect(() => {
|
|
if (radius) {
|
|
setLocalRadius(radius);
|
|
}
|
|
}, [radius]);
|
|
|
|
const handlePositionChange = (lat: number, lng: number) => {
|
|
if (disabled) return;
|
|
setPosition([lat, lng]);
|
|
onChange(lat, lng, localRadius);
|
|
};
|
|
|
|
const handleRadiusUpdate = (newRadius: number) => {
|
|
if (disabled) return;
|
|
setLocalRadius(newRadius);
|
|
if (position) {
|
|
const lat = Array.isArray(position) ? position[0] : (position as any).lat;
|
|
const lng = Array.isArray(position) ? position[1] : (position as any).lng;
|
|
onChange(lat, lng, newRadius);
|
|
}
|
|
};
|
|
|
|
const handleRadiusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (disabled) return;
|
|
const newRadius = Number(e.target.value);
|
|
handleRadiusUpdate(newRadius);
|
|
};
|
|
|
|
const handleClear = () => {
|
|
if (disabled) return;
|
|
setPosition(null);
|
|
onChange(null, null, localRadius);
|
|
}
|
|
|
|
const handleLocate = () => {
|
|
setLocating(true);
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => {
|
|
const { latitude, longitude } = pos.coords;
|
|
if (mapRef.current) {
|
|
mapRef.current.flyTo([latitude, longitude], 15);
|
|
}
|
|
handlePositionChange(latitude, longitude);
|
|
setLocating(false);
|
|
},
|
|
(err) => {
|
|
console.error("Geolocation error:", err);
|
|
setLocating(false);
|
|
},
|
|
{ enableHighAccuracy: true }
|
|
);
|
|
};
|
|
|
|
const defaultCenter = [51.505, -0.09] as L.LatLngExpression; // London fallback
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<Label>Branch Location</Label>
|
|
{!disabled && position && (
|
|
<button type="button" onClick={handleClear} className="text-xs text-red-500 hover:underline">
|
|
Clear Location
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="h-[300px] w-full rounded-md border border-gray-300 overflow-hidden relative z-0">
|
|
{!disabled && (
|
|
<button
|
|
type="button"
|
|
onClick={handleLocate}
|
|
title="Find My Location"
|
|
disabled={locating}
|
|
className="absolute top-2 right-2 z-10 bg-white hover:bg-gray-100 text-gray-700 p-2 rounded shadow-md border border-gray-300 flex items-center justify-center transition-colors disabled:opacity-50"
|
|
>
|
|
<LocateFixed className={`h-5 w-5 ${locating ? 'animate-pulse text-blue-500' : ''}`} />
|
|
</button>
|
|
)}
|
|
<MapContainer
|
|
ref={mapRef}
|
|
center={position || defaultCenter}
|
|
zoom={13}
|
|
scrollWheelZoom={true}
|
|
style={{ height: '100%', width: '100%', zIndex: 0 }}
|
|
>
|
|
<TileLayer
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
/>
|
|
<LocationMarker
|
|
position={position}
|
|
radius={localRadius}
|
|
onPositionChange={handlePositionChange}
|
|
onRadiusChange={handleRadiusUpdate}
|
|
/>
|
|
</MapContainer>
|
|
</div>
|
|
|
|
<div className="flex gap-4">
|
|
<div className="w-1/3">
|
|
<Label className="text-xs">Radius (meters)</Label>
|
|
<Input
|
|
type="number"
|
|
value={localRadius}
|
|
onChange={handleRadiusChange}
|
|
disabled={disabled || !position}
|
|
min={10}
|
|
step={10}
|
|
/>
|
|
</div>
|
|
<div className="w-2/3 flex items-end">
|
|
<p className="text-xs text-gray-500 mb-2">Drag the blue dot on the circle edge to adjust radius, or enter meters manually.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|