253 lines
8.6 KiB
TypeScript
253 lines
8.6 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { useJsApiLoader, GoogleMap, InfoWindow } from '@react-google-maps/api';
|
|
|
|
const libraries: ("marker")[] = ["marker"];
|
|
|
|
import { CustomAdvancedMarker } from './CustomAdvancedMarker';
|
|
import { PIPELINE, BASE_URL } from '../../api/config';
|
|
import { Loader2, MapPin, ChevronDown, ChevronUp } from 'lucide-react';
|
|
|
|
// Interfaces for the API response
|
|
interface StoreLocation {
|
|
latitude: number;
|
|
longitude: number;
|
|
}
|
|
|
|
interface Store {
|
|
area: string;
|
|
business_name: string;
|
|
distance_km: number;
|
|
location: StoreLocation;
|
|
route_name: string;
|
|
store_code: string;
|
|
}
|
|
|
|
interface NearestStoresResponse {
|
|
message: string;
|
|
response: {
|
|
count: number;
|
|
stores: Store[];
|
|
success: boolean;
|
|
};
|
|
}
|
|
|
|
const mapContainerStyle = {
|
|
width: '100%',
|
|
height: '100%',
|
|
minHeight: '400px',
|
|
borderRadius: '16px'
|
|
};
|
|
|
|
const defaultCenter = { lat: 12.9716, lng: 77.5946 };
|
|
|
|
export function NearestStoresMap() {
|
|
const [userLocation, setUserLocation] = useState<{ lat: number, lng: number } | null>(null);
|
|
const [stores, setStores] = useState<Store[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedStore, setSelectedStore] = useState<Store | null>(null);
|
|
const [isListExpanded, setIsListExpanded] = useState(false);
|
|
|
|
const isLocalhost = typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
|
|
|
|
const { isLoaded } = useJsApiLoader({
|
|
id: 'google-map-script',
|
|
googleMapsApiKey: isLocalhost ? '' : (import.meta.env.VITE_GOOGLE_MAPS_API_KEY || ''),
|
|
libraries
|
|
});
|
|
|
|
const fetchNearestStores = async (lat: number, lng: number) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const response = await fetch(`${BASE_URL}${PIPELINE.endpoints.nearestStores}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'accept': 'application/json, text/plain, */*',
|
|
'content-type': 'application/json',
|
|
'groupid': '25',
|
|
'orgid': '57',
|
|
'templateid': '189',
|
|
'x-pipeline-version': 'latest'
|
|
},
|
|
body: JSON.stringify({
|
|
latitude: lat,
|
|
longitude: lng
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch stores: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json() as NearestStoresResponse;
|
|
if (data.response && data.response.stores) {
|
|
setStores(data.response.stores);
|
|
}
|
|
} catch (err: any) {
|
|
setError(err.message || 'An error occurred while fetching stores.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!navigator.geolocation) {
|
|
setError('Geolocation is not supported by your browser.');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
const lat = position.coords.latitude;
|
|
const lng = position.coords.longitude;
|
|
setUserLocation({ lat, lng });
|
|
fetchNearestStores(lat, lng);
|
|
},
|
|
(err) => {
|
|
setError(err.message || 'Failed to get location');
|
|
setLoading(false);
|
|
},
|
|
{ enableHighAccuracy: true }
|
|
);
|
|
}, []);
|
|
|
|
const onLoad = useCallback(function callback(map: google.maps.Map) {
|
|
if (userLocation) {
|
|
const bounds = new window.google.maps.LatLngBounds();
|
|
bounds.extend(userLocation);
|
|
stores.forEach(store => {
|
|
if (store.location) {
|
|
bounds.extend({ lat: store.location.latitude, lng: store.location.longitude });
|
|
}
|
|
});
|
|
if (stores.length > 0) {
|
|
map.fitBounds(bounds);
|
|
}
|
|
}
|
|
}, [userLocation, stores]);
|
|
|
|
if (!isLoaded) {
|
|
return (
|
|
<div className="w-full h-[400px] flex items-center justify-center bg-slate-50 rounded-xl border border-slate-200">
|
|
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full flex flex-col bg-transparent">
|
|
{error && (
|
|
<div className="bg-red-50 text-red-600 p-3 m-4 rounded-lg text-sm border border-red-100">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{loading && !userLocation && (
|
|
<div className="text-sm text-slate-500 flex items-center gap-2 p-4">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Locating you...
|
|
</div>
|
|
)}
|
|
|
|
<div className="w-full h-[50vh] min-h-[350px] relative pt-4">
|
|
<GoogleMap
|
|
mapContainerStyle={mapContainerStyle}
|
|
center={userLocation || defaultCenter}
|
|
zoom={userLocation ? 13 : 5}
|
|
onLoad={onLoad}
|
|
options={{
|
|
streetViewControl: false,
|
|
mapTypeControl: false,
|
|
fullscreenControl: false,
|
|
mapId: "DEMO_MAP_ID"
|
|
}}
|
|
>
|
|
{/* User's Current Location Marker */}
|
|
{userLocation && (
|
|
<CustomAdvancedMarker
|
|
position={userLocation}
|
|
iconUrl="https://maps.google.com/mapfiles/ms/icons/blue-dot.png"
|
|
title="You are here"
|
|
/>
|
|
)}
|
|
|
|
{/* Stores Markers */}
|
|
{stores.map((store) => (
|
|
store.location && (
|
|
<CustomAdvancedMarker
|
|
key={store.store_code}
|
|
position={{ lat: store.location.latitude, lng: store.location.longitude }}
|
|
onClick={() => setSelectedStore(store)}
|
|
title={store.business_name}
|
|
/>
|
|
)
|
|
))}
|
|
|
|
{/* Info Window for Selected Store */}
|
|
{selectedStore && selectedStore.location && (
|
|
<InfoWindow
|
|
position={{ lat: selectedStore.location.latitude, lng: selectedStore.location.longitude }}
|
|
onCloseClick={() => setSelectedStore(null)}
|
|
>
|
|
<div className="p-1 max-w-[200px]">
|
|
<h3 className="font-semibold text-sm text-slate-800">{selectedStore.business_name}</h3>
|
|
<p className="text-xs text-slate-500 mt-1">{selectedStore.area}</p>
|
|
<div className="flex justify-between items-center mt-2 pt-2 border-t border-slate-100">
|
|
<span className="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded">
|
|
{selectedStore.route_name}
|
|
</span>
|
|
<span className="text-[10px] text-slate-400 font-medium">
|
|
{selectedStore.distance_km} km
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</InfoWindow>
|
|
)}
|
|
</GoogleMap>
|
|
</div>
|
|
|
|
<div className="mt-4 mb-4 bg-card rounded-2xl shadow-[0_1px_0_0_rgba(0,0,0,0.02),0_20px_40px_-24px_rgba(20,80,60,0.15)] p-5 flex flex-col gap-4">
|
|
<div className="flex justify-between items-center cursor-pointer" onClick={() => setIsListExpanded(!isListExpanded)}>
|
|
<h3 className="font-bold text-[17px] text-strong flex items-center gap-2">
|
|
<MapPin className="w-5 h-5 text-primary" />
|
|
Nearest Stores ({stores.length})
|
|
</h3>
|
|
<button className="text-slate-400 hover:text-slate-600 transition-colors p-1">
|
|
{isListExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
|
</button>
|
|
</div>
|
|
{isListExpanded && (
|
|
<>
|
|
{loading && stores.length === 0 && (
|
|
<div className="py-4 text-center text-sm text-slate-500">
|
|
Fetching nearby stores...
|
|
</div>
|
|
)}
|
|
{!loading && stores.length === 0 && userLocation && (
|
|
<div className="py-4 text-center text-sm text-slate-500">
|
|
No stores found nearby.
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col gap-3">
|
|
{stores.map(store => (
|
|
<div key={store.store_code} className="z-card !p-3 hover:!border-primary/40 cursor-pointer" onClick={() => setSelectedStore(store)}>
|
|
<div className="flex justify-between items-start mb-1">
|
|
<span className="font-bold text-[17px] text-strong line-clamp-1">{store.business_name}</span>
|
|
</div>
|
|
<p className="text-sm text-muted line-clamp-1">{store.area}</p>
|
|
<div className="flex justify-between items-center mt-3 pt-3 border-t border-slate-200/60">
|
|
<span className="text-xs font-semibold text-primary uppercase tracking-wider">{store.route_name}</span>
|
|
<span className="text-xs font-bold text-strong">{store.distance_km} km</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|