Spaces:
Sleeping
Sleeping
| import { useState, useEffect } from 'react'; | |
| import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'; | |
| import 'leaflet/dist/leaflet.css'; | |
| import L from 'leaflet'; | |
| import api from '../api/api'; | |
| import { useAuth } from '../context/AuthContext'; | |
| import { Star, MapPin, Navigation, Crosshair, Filter, Loader2, Search as SearchIcon } from 'lucide-react'; | |
| import { useNavigate } from 'react-router-dom'; | |
| // Fix for default marker icons in Leaflet | |
| delete L.Icon.Default.prototype._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', | |
| }); | |
| const RecenterMap = ({ coords }) => { | |
| const map = useMap(); | |
| useEffect(() => { | |
| if (coords) { | |
| map.setView(coords, 13); | |
| } | |
| }, [coords, map]); | |
| return null; | |
| }; | |
| const ExploreMap = () => { | |
| const [photographers, setPhotographers] = useState([]); | |
| const [userLocation, setUserLocation] = useState(null); | |
| const [loading, setLoading] = useState(true); | |
| const [isFilterOpen, setIsFilterOpen] = useState(false); | |
| const [filters, setFilters] = useState({ | |
| specialty: '', | |
| price: 'all', | |
| availableNow: false | |
| }); | |
| const navigate = useNavigate(); | |
| useEffect(() => { | |
| // Get user's current location | |
| if (navigator.geolocation) { | |
| navigator.geolocation.getCurrentPosition( | |
| (position) => { | |
| const coords = [position.coords.latitude, position.coords.longitude]; | |
| setUserLocation(coords); | |
| fetchNearby(coords[0], coords[1]); | |
| }, | |
| (err) => { | |
| console.error('Location error:', err); | |
| // Default to some city center if blocked (e.g. Hyderabad) | |
| const defaultCoords = [17.3850, 78.4867]; | |
| setUserLocation(defaultCoords); | |
| fetchNearby(defaultCoords[0], defaultCoords[1]); | |
| } | |
| ); | |
| } | |
| }, []); | |
| const fetchNearby = async (lat, lng) => { | |
| try { | |
| setLoading(true); | |
| const response = await api.get(`/users/nearby?lat=${lat}&lng=${lng}&radius=10000`); | |
| setPhotographers(response.data); | |
| setLoading(false); | |
| } catch (error) { | |
| console.error('Failed to fetch nearby photographers:', error); | |
| setLoading(false); | |
| } | |
| }; | |
| const filteredPhotographers = photographers.filter(pg => { | |
| if (filters.specialty && !pg.specialty?.toLowerCase().includes(filters.specialty.toLowerCase())) return false; | |
| if (filters.price !== 'all') { | |
| const price = pg.price || 0; | |
| if (filters.price === 'budget' && price > 50) return false; | |
| if (filters.price === 'mid' && (price < 50 || price > 150)) return false; | |
| if (filters.price === 'pro' && price < 150) return false; | |
| } | |
| return true; | |
| }); | |
| return ( | |
| <div className="h-[calc(100vh-100px)] -mt-8 flex flex-col relative overflow-hidden bg-gray-50"> | |
| {/* Control Bar */} | |
| <div className="absolute top-6 left-6 right-6 z-[1000] flex items-center space-x-4"> | |
| <div className="flex-1 max-w-lg bg-white/90 backdrop-blur-xl rounded-[2rem] shadow-2xl border border-white/20 p-2 flex items-center"> | |
| <div className="pl-4 pr-3 text-blue-600"> | |
| <SearchIcon size={20} /> | |
| </div> | |
| <input | |
| type="text" | |
| placeholder="Search for a city or specialty..." | |
| className="flex-1 bg-transparent border-none outline-none font-bold text-sm text-gray-800 placeholder:text-gray-400" | |
| /> | |
| <button | |
| onClick={() => setIsFilterOpen(!isFilterOpen)} | |
| className={`p-3 rounded-2xl transition-all ${isFilterOpen ? 'bg-blue-600 text-white shadow-lg' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`} | |
| > | |
| <Filter size={18} /> | |
| </button> | |
| </div> | |
| <div className="bg-white/90 backdrop-blur-xl px-6 py-3 rounded-2xl shadow-xl border border-white/20 flex items-center space-x-2"> | |
| <Crosshair size={18} className="text-blue-600" /> | |
| <span className="text-sm font-black text-gray-800 uppercase tracking-wider">Nearby Pros: {filteredPhotographers.length}</span> | |
| </div> | |
| </div> | |
| {/* Filter Drawer */} | |
| {isFilterOpen && ( | |
| <div className="absolute top-24 left-6 z-[1000] w-72 bg-white rounded-3xl shadow-2xl border border-gray-100 p-6 animate-in slide-in-from-top-4 duration-300"> | |
| <h4 className="text-xs font-black text-gray-400 uppercase tracking-widest mb-4">Refine Discovery</h4> | |
| <div className="space-y-6"> | |
| <div className="space-y-2"> | |
| <label className="text-[10px] font-black text-gray-400 uppercase">Specialty</label> | |
| <input | |
| type="text" | |
| value={filters.specialty} | |
| onChange={(e) => setFilters({ ...filters, specialty: e.target.value })} | |
| className="w-full p-3 bg-gray-50 rounded-xl border border-gray-100 text-sm font-bold" | |
| placeholder="Wedding, Portrait..." | |
| /> | |
| </div> | |
| <div className="space-y-2"> | |
| <label className="text-[10px] font-black text-gray-400 uppercase">Price Range</label> | |
| <div className="grid grid-cols-2 gap-2"> | |
| {['all', 'budget', 'mid', 'pro'].map(p => ( | |
| <button | |
| key={p} | |
| onClick={() => setFilters({ ...filters, price: p })} | |
| className={`py-2 rounded-xl text-[10px] font-black uppercase tracking-wider border transition-all ${filters.price === p ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-500 border-gray-100 hover:border-blue-200'}`} | |
| > | |
| {p} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| <label className="flex items-center space-x-3 cursor-pointer group"> | |
| <input | |
| type="checkbox" | |
| checked={filters.availableNow} | |
| onChange={(e) => setFilters({ ...filters, availableNow: e.target.checked })} | |
| className="hidden" | |
| /> | |
| <div className={`w-10 h-6 rounded-full p-1 transition-colors ${filters.availableNow ? 'bg-green-500' : 'bg-gray-200'}`}> | |
| <div className={`bg-white w-4 h-4 rounded-full transition-transform shadow-sm ${filters.availableNow ? 'translate-x-4' : ''}`}></div> | |
| </div> | |
| <span className="text-xs font-bold text-gray-700">Available Now</span> | |
| </label> | |
| </div> | |
| </div> | |
| )} | |
| {/* Map Component */} | |
| <div className="flex-1 relative"> | |
| {userLocation ? ( | |
| <MapContainer | |
| center={userLocation} | |
| zoom={13} | |
| className="h-full w-full grayscale-[0.2] contrast-[1.1]" | |
| zoomControl={false} | |
| > | |
| <TileLayer | |
| url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" | |
| attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' | |
| /> | |
| <RecenterMap coords={userLocation} /> | |
| {/* User Marker */} | |
| <Marker position={userLocation} icon={new L.DivIcon({ | |
| className: 'user-location-marker', | |
| html: `<div class="w-4 h-4 bg-blue-600 rounded-full border-2 border-white shadow-xl animate-pulse"></div>` | |
| })}> | |
| <Popup>You are here</Popup> | |
| </Marker> | |
| {/* Photographer Markers */} | |
| {filteredPhotographers.map(pg => ( | |
| <Marker | |
| key={pg._id} | |
| position={[pg.location.coordinates[1], pg.location.coordinates[0]]} | |
| icon={new L.DivIcon({ | |
| className: 'photographer-marker', | |
| html: `<div class="bg-white p-1 rounded-xl shadow-2xl border border-gray-100 hover:scale-110 transition-transform"><img src="${pg.profilePicture}" class="w-10 h-10 rounded-lg object-cover" /></div>` | |
| })} | |
| > | |
| <Popup className="custom-popup"> | |
| <div className="p-4 min-w-[200px] text-left"> | |
| <div className="flex items-center space-x-3 mb-4"> | |
| <img src={pg.profilePicture} className="w-12 h-12 rounded-2xl object-cover shadow-lg" alt="" /> | |
| <div> | |
| <h4 className="font-black text-gray-900 leading-tight">{pg.firstName}</h4> | |
| <p className="text-[10px] text-blue-600 font-bold uppercase tracking-widest">{pg.specialty}</p> | |
| </div> | |
| </div> | |
| <div className="flex justify-between items-center mb-4 text-xs font-black"> | |
| <div className="flex items-center text-amber-500"> | |
| <Star size={12} className="fill-current mr-1" /> | |
| <span>{pg.rating?.toFixed(1) || 4.9}</span> | |
| </div> | |
| <div className="text-gray-900">${pg.price}/hr</div> | |
| </div> | |
| <button | |
| onClick={() => navigate(`/photographer/${pg._id}`)} | |
| className="w-full bg-gray-900 text-white py-3 rounded-xl font-black text-[10px] uppercase tracking-widest hover:bg-gray-800 transition shadow-xl" | |
| > | |
| View Portfolio | |
| </button> | |
| </div> | |
| </Popup> | |
| </Marker> | |
| ))} | |
| </MapContainer> | |
| ) : ( | |
| <div className="flex flex-col items-center justify-center h-full space-y-4"> | |
| <Loader2 className="animate-spin text-blue-600" size={48} /> | |
| <p className="text-gray-500 font-bold">Initializing SnapLocal Pro Map...</p> | |
| </div> | |
| )} | |
| </div> | |
| <style>{` | |
| .leaflet-popup-content-wrapper { | |
| padding: 0 ; | |
| border-radius: 2rem ; | |
| overflow: hidden ; | |
| box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.1) ; | |
| } | |
| .leaflet-popup-content { | |
| margin: 0 ; | |
| } | |
| .leaflet-popup-tip-container { | |
| display: none ; | |
| } | |
| `}</style> | |
| </div> | |
| ); | |
| }; | |
| export default ExploreMap; | |