map search
This commit is contained in:
@@ -31,8 +31,9 @@ const GoogleMapWithRadius: React.FC<GoogleMapWithRadiusProps> = ({
|
|||||||
// Destructure mapOptions to create stable references
|
// Destructure mapOptions to create stable references
|
||||||
const { zoom = 12 } = mapOptions;
|
const { zoom = 12 } = mapOptions;
|
||||||
|
|
||||||
// Get API key from environment
|
// Get API key and Map ID from environment
|
||||||
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_PUBLIC_API_KEY;
|
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_PUBLIC_API_KEY;
|
||||||
|
const mapId = process.env.REACT_APP_GOOGLE_MAPS_MAP_ID;
|
||||||
|
|
||||||
// Refs for map container and instances
|
// Refs for map container and instances
|
||||||
const mapRef = useRef<HTMLDivElement>(null);
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -60,17 +61,21 @@ const GoogleMapWithRadius: React.FC<GoogleMapWithRadiusProps> = ({
|
|||||||
|
|
||||||
if (!mapRef.current) return;
|
if (!mapRef.current) return;
|
||||||
|
|
||||||
// Create map
|
// Create map configuration
|
||||||
const map = new google.maps.Map(mapRef.current, {
|
const mapConfig: google.maps.MapOptions = {
|
||||||
|
mapId: mapId,
|
||||||
center: mapCenter,
|
center: mapCenter,
|
||||||
zoom: zoom,
|
zoom: zoom,
|
||||||
|
maxZoom: 15, // Prevent users from zooming too close
|
||||||
zoomControl: true,
|
zoomControl: true,
|
||||||
mapTypeControl: false,
|
mapTypeControl: false,
|
||||||
scaleControl: true,
|
scaleControl: true,
|
||||||
streetViewControl: false,
|
streetViewControl: false,
|
||||||
rotateControl: false,
|
rotateControl: false,
|
||||||
fullscreenControl: false,
|
fullscreenControl: false,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const map = new google.maps.Map(mapRef.current, mapConfig);
|
||||||
|
|
||||||
mapInstanceRef.current = map;
|
mapInstanceRef.current = map;
|
||||||
|
|
||||||
@@ -101,7 +106,7 @@ const GoogleMapWithRadius: React.FC<GoogleMapWithRadiusProps> = ({
|
|||||||
}
|
}
|
||||||
mapInstanceRef.current = null;
|
mapInstanceRef.current = null;
|
||||||
};
|
};
|
||||||
}, [apiKey, mapCenter, zoom]);
|
}, [apiKey, mapId, mapCenter, zoom]);
|
||||||
|
|
||||||
// Update map center and circle when coordinates change
|
// Update map center and circle when coordinates change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
98
frontend/src/components/ItemMarkerInfo.tsx
Normal file
98
frontend/src/components/ItemMarkerInfo.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Item } from '../types';
|
||||||
|
|
||||||
|
interface ItemMarkerInfoProps {
|
||||||
|
item: Item;
|
||||||
|
onViewDetails?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemMarkerInfo: React.FC<ItemMarkerInfoProps> = ({ item, onViewDetails }) => {
|
||||||
|
const getPriceDisplay = () => {
|
||||||
|
if (item.pricePerDay !== undefined) {
|
||||||
|
return Number(item.pricePerDay) === 0
|
||||||
|
? "Free to Borrow"
|
||||||
|
: `$${Math.floor(Number(item.pricePerDay))}/Day`;
|
||||||
|
} else if (item.pricePerHour !== undefined) {
|
||||||
|
return Number(item.pricePerHour) === 0
|
||||||
|
? "Free to Borrow"
|
||||||
|
: `$${Math.floor(Number(item.pricePerHour))}/Hour`;
|
||||||
|
}
|
||||||
|
return 'Contact for pricing';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocationDisplay = () => {
|
||||||
|
return item.city && item.state
|
||||||
|
? `${item.city}, ${item.state}`
|
||||||
|
: 'Location not specified';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 'min(280px, 90vw)', maxWidth: '280px' }}>
|
||||||
|
<div className="card border-0">
|
||||||
|
{item.images && item.images[0] ? (
|
||||||
|
<img
|
||||||
|
src={item.images[0]}
|
||||||
|
className="card-img-top"
|
||||||
|
alt={item.name}
|
||||||
|
style={{
|
||||||
|
height: '120px',
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: '8px 8px 0 0'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="bg-light d-flex align-items-center justify-content-center"
|
||||||
|
style={{
|
||||||
|
height: '120px',
|
||||||
|
borderRadius: '8px 8px 0 0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-image text-muted" style={{ fontSize: '1.5rem' }}></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card-body p-3">
|
||||||
|
<h6 className="card-title mb-2 text-dark fw-bold text-truncate">
|
||||||
|
{item.name}
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-primary fw-bold">
|
||||||
|
{getPriceDisplay()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted small mb-2">
|
||||||
|
<i className="bi bi-geo-alt"></i> {getLocationDisplay()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.description && (
|
||||||
|
<p className="card-text text-muted small mb-3"
|
||||||
|
style={{
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.2em',
|
||||||
|
maxHeight: '2.4em'
|
||||||
|
}}>
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="d-grid">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={onViewDetails}
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ItemMarkerInfo;
|
||||||
432
frontend/src/components/SearchResultsMap.tsx
Normal file
432
frontend/src/components/SearchResultsMap.tsx
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
import React, {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
|
import { Loader } from "@googlemaps/js-api-loader";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { Item } from "../types";
|
||||||
|
import ItemMarkerInfo from "./ItemMarkerInfo";
|
||||||
|
|
||||||
|
// Debounce utility function
|
||||||
|
function debounce<T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
wait: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: NodeJS.Timeout;
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func(...args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResultsMapProps {
|
||||||
|
items: Item[];
|
||||||
|
searchLocation?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
onItemSelect?: (item: Item) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchResultsMap: React.FC<SearchResultsMapProps> = ({
|
||||||
|
items,
|
||||||
|
searchLocation,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
onItemSelect,
|
||||||
|
}) => {
|
||||||
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapInstanceRef = useRef<google.maps.Map | null>(null);
|
||||||
|
const markersRef = useRef<google.maps.marker.AdvancedMarkerElement[]>([]);
|
||||||
|
const infoWindowRef = useRef<google.maps.InfoWindow | null>(null);
|
||||||
|
const mapInitializedRef = useRef<boolean>(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_PUBLIC_API_KEY;
|
||||||
|
const mapId = process.env.REACT_APP_GOOGLE_MAPS_MAP_ID;
|
||||||
|
|
||||||
|
// Clean up markers
|
||||||
|
const clearMarkers = useCallback(() => {
|
||||||
|
markersRef.current.forEach((marker) => {
|
||||||
|
marker.map = null;
|
||||||
|
});
|
||||||
|
markersRef.current = [];
|
||||||
|
if (infoWindowRef.current) {
|
||||||
|
infoWindowRef.current.close();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Create marker for an item
|
||||||
|
const createItemMarker = useCallback(
|
||||||
|
async (item: Item, map: google.maps.Map) => {
|
||||||
|
if (!item.latitude || !item.longitude) return null;
|
||||||
|
|
||||||
|
const { AdvancedMarkerElement } = (await google.maps.importLibrary(
|
||||||
|
"marker"
|
||||||
|
)) as google.maps.MarkerLibrary;
|
||||||
|
|
||||||
|
// Create marker element
|
||||||
|
const isMobile = window.innerWidth < 768;
|
||||||
|
const markerSize = isMobile ? 48 : 40;
|
||||||
|
|
||||||
|
const markerElement = document.createElement("div");
|
||||||
|
markerElement.className =
|
||||||
|
"d-flex align-items-center justify-content-center";
|
||||||
|
markerElement.style.cssText = `
|
||||||
|
width: ${markerSize}px;
|
||||||
|
height: ${markerSize}px;
|
||||||
|
background-color: #0d6efd;
|
||||||
|
border: 3px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
touch-action: manipulation;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add icon
|
||||||
|
const icon = document.createElement("i");
|
||||||
|
icon.className = "bi bi-geo-fill text-white";
|
||||||
|
icon.style.fontSize = "16px";
|
||||||
|
markerElement.appendChild(icon);
|
||||||
|
|
||||||
|
// Hover effects
|
||||||
|
markerElement.addEventListener("mouseenter", () => {
|
||||||
|
markerElement.style.transform = "scale(1.1)";
|
||||||
|
});
|
||||||
|
markerElement.addEventListener("mouseleave", () => {
|
||||||
|
markerElement.style.transform = "scale(1)";
|
||||||
|
});
|
||||||
|
|
||||||
|
const marker = new AdvancedMarkerElement({
|
||||||
|
position: { lat: item.latitude, lng: item.longitude },
|
||||||
|
map: map,
|
||||||
|
content: markerElement,
|
||||||
|
title: item.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add click listener
|
||||||
|
marker.addListener("click", () => {
|
||||||
|
if (infoWindowRef.current) {
|
||||||
|
infoWindowRef.current.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoWindow = new google.maps.InfoWindow({
|
||||||
|
headerDisabled: true,
|
||||||
|
maxWidth:
|
||||||
|
window.innerWidth < 768
|
||||||
|
? Math.min(300, window.innerWidth - 40)
|
||||||
|
: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create React container for info window content
|
||||||
|
const infoContainer = document.createElement("div");
|
||||||
|
const root = createRoot(infoContainer);
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<ItemMarkerInfo
|
||||||
|
item={item}
|
||||||
|
onViewDetails={() => {
|
||||||
|
infoWindow.close();
|
||||||
|
if (onItemSelect) {
|
||||||
|
onItemSelect(item);
|
||||||
|
} else {
|
||||||
|
window.location.href = `/items/${item.id}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
infoWindow.setContent(infoContainer);
|
||||||
|
infoWindow.open(map, marker);
|
||||||
|
infoWindowRef.current = infoWindow;
|
||||||
|
});
|
||||||
|
|
||||||
|
return marker;
|
||||||
|
},
|
||||||
|
[onItemSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add markers for all items
|
||||||
|
const addMarkersToMap = useCallback(
|
||||||
|
async (map: google.maps.Map, items: Item[]) => {
|
||||||
|
clearMarkers();
|
||||||
|
|
||||||
|
const validItems = items.filter(
|
||||||
|
(item) => item.latitude && item.longitude
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const item of validItems) {
|
||||||
|
const marker = await createItemMarker(item, map);
|
||||||
|
if (marker) {
|
||||||
|
markersRef.current.push(marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[createItemMarker, clearMarkers]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate map bounds to fit all markers with appropriate zoom levels
|
||||||
|
const fitMapToMarkers = useCallback((map: google.maps.Map, items: Item[]) => {
|
||||||
|
const validItems = items.filter((item) => item.latitude && item.longitude);
|
||||||
|
|
||||||
|
if (validItems.length === 0) return;
|
||||||
|
|
||||||
|
const bounds = new google.maps.LatLngBounds();
|
||||||
|
|
||||||
|
validItems.forEach((item) => {
|
||||||
|
if (item.latitude && item.longitude) {
|
||||||
|
bounds.extend(new google.maps.LatLng(item.latitude, item.longitude));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validItems.length === 1) {
|
||||||
|
// Single marker - center and set neighborhood-level zoom (not street-level)
|
||||||
|
map.setCenter(bounds.getCenter());
|
||||||
|
map.setZoom(10); // Changed from 12 to 10 for better context
|
||||||
|
} else {
|
||||||
|
// Multiple markers - calculate distance to determine zoom strategy
|
||||||
|
const northeast = bounds.getNorthEast();
|
||||||
|
const southwest = bounds.getSouthWest();
|
||||||
|
|
||||||
|
// Calculate approximate distance between bounds corners in miles
|
||||||
|
const distance =
|
||||||
|
google.maps.geometry.spherical.computeDistanceBetween(
|
||||||
|
southwest,
|
||||||
|
northeast
|
||||||
|
) / 1609.34; // Convert meters to miles
|
||||||
|
|
||||||
|
if (distance < 0.5) {
|
||||||
|
// Very close items - show broader neighborhood context
|
||||||
|
const center = bounds.getCenter();
|
||||||
|
map.setCenter(center);
|
||||||
|
map.setZoom(11); // Neighborhood level for very close items
|
||||||
|
} else if (distance < 5) {
|
||||||
|
// Moderate spread - use fitBounds with generous padding and min zoom
|
||||||
|
map.fitBounds(bounds, 120); // Increased padding from 50 to 120
|
||||||
|
|
||||||
|
// Ensure we don't zoom too close
|
||||||
|
const currentZoom = map.getZoom();
|
||||||
|
if (currentZoom && currentZoom > 12) {
|
||||||
|
map.setZoom(12);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Wide spread - use fitBounds with standard padding
|
||||||
|
map.fitBounds(bounds, 80);
|
||||||
|
|
||||||
|
// Ensure minimum zoom for very spread out items
|
||||||
|
const currentZoom = map.getZoom();
|
||||||
|
if (currentZoom && currentZoom < 6) {
|
||||||
|
map.setZoom(6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Check if any items have coordinates before initializing map
|
||||||
|
const hasMappableItems = items.some(
|
||||||
|
(item) => item.latitude && item.longitude
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debounced function to update map markers (prevents excessive API calls during rapid updates)
|
||||||
|
const debouncedUpdateMap = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce(async (map: google.maps.Map, items: Item[]) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await addMarkersToMap(map, items);
|
||||||
|
fitMapToMarkers(map, items);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error updating map:", err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, 300), // 300ms debounce delay
|
||||||
|
[addMarkersToMap, fitMapToMarkers]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize map
|
||||||
|
useEffect(() => {
|
||||||
|
if (!apiKey || !mapRef.current || !hasMappableItems) return;
|
||||||
|
|
||||||
|
// If map is already initialized, just update markers using debounced function
|
||||||
|
if (mapInitializedRef.current && mapInstanceRef.current) {
|
||||||
|
debouncedUpdateMap(mapInstanceRef.current, items);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
const initializeMap = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const loader = new Loader({
|
||||||
|
apiKey,
|
||||||
|
version: "weekly",
|
||||||
|
});
|
||||||
|
|
||||||
|
await loader.importLibrary("maps");
|
||||||
|
await loader.importLibrary("marker");
|
||||||
|
await loader.importLibrary("geometry");
|
||||||
|
|
||||||
|
if (!mounted || !mapRef.current) return;
|
||||||
|
|
||||||
|
// Default map center (will be updated based on search results)
|
||||||
|
const defaultCenter = { lat: 39.8283, lng: -98.5795 }; // Geographic center of US
|
||||||
|
|
||||||
|
const mapConfig: google.maps.MapOptions = {
|
||||||
|
mapId: mapId,
|
||||||
|
center: defaultCenter,
|
||||||
|
zoom: 4,
|
||||||
|
maxZoom: 15, // Prevent users from zooming too close
|
||||||
|
zoomControl: true,
|
||||||
|
mapTypeControl: false,
|
||||||
|
scaleControl: false,
|
||||||
|
streetViewControl: false,
|
||||||
|
rotateControl: false,
|
||||||
|
fullscreenControl: true,
|
||||||
|
mapTypeId: google.maps.MapTypeId.ROADMAP,
|
||||||
|
};
|
||||||
|
|
||||||
|
const map = new google.maps.Map(mapRef.current, mapConfig);
|
||||||
|
|
||||||
|
mapInstanceRef.current = map;
|
||||||
|
mapInitializedRef.current = true;
|
||||||
|
|
||||||
|
// Add markers if items exist
|
||||||
|
if (items.length > 0) {
|
||||||
|
await addMarkersToMap(map, items);
|
||||||
|
fitMapToMarkers(map, items);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load Google Maps:", err);
|
||||||
|
if (mounted) {
|
||||||
|
setError("Failed to load map. Please try again later.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeMap();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
// Don't clear markers on cleanup - preserve for next use
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
apiKey,
|
||||||
|
mapId,
|
||||||
|
hasMappableItems,
|
||||||
|
items,
|
||||||
|
addMarkersToMap,
|
||||||
|
fitMapToMarkers,
|
||||||
|
clearMarkers,
|
||||||
|
debouncedUpdateMap,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// This useEffect is now handled by the main initialization effect above
|
||||||
|
|
||||||
|
const defaultStyle = {
|
||||||
|
height: "600px",
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: "#f8f9fa",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show message when no items have location data
|
||||||
|
if (!hasMappableItems) {
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ ...defaultStyle, ...style }}>
|
||||||
|
<div className="d-flex align-items-center justify-content-center h-100">
|
||||||
|
<div className="text-center">
|
||||||
|
<i
|
||||||
|
className="bi bi-geo-alt text-muted mb-3"
|
||||||
|
style={{ fontSize: "3rem" }}
|
||||||
|
></i>
|
||||||
|
<h5 className="text-muted">No Location Data</h5>
|
||||||
|
<p className="text-muted small mb-0">
|
||||||
|
None of the search results have location information available for
|
||||||
|
mapping.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ ...defaultStyle, ...style }}>
|
||||||
|
<div className="d-flex align-items-center justify-content-center h-100">
|
||||||
|
<div className="text-center">
|
||||||
|
<i
|
||||||
|
className="bi bi-exclamation-triangle text-warning mb-2"
|
||||||
|
style={{ fontSize: "3rem" }}
|
||||||
|
></i>
|
||||||
|
<p className="text-muted">{error}</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary btn-sm"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{ ...defaultStyle, ...style, position: "relative" }}
|
||||||
|
>
|
||||||
|
{isLoading && (
|
||||||
|
<div
|
||||||
|
className="position-absolute d-flex align-items-center justify-content-center w-100 h-100"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgba(248, 249, 250, 0.8)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="spinner-border text-primary mb-2" role="status">
|
||||||
|
<span className="visually-hidden">Loading map...</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted small">Loading map...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={mapRef}
|
||||||
|
style={{ width: "100%", height: "100%", borderRadius: "8px" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isLoading && items.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="position-absolute bottom-0 start-0 bg-white px-3 py-2 rounded-top-2 shadow-sm"
|
||||||
|
style={{ margin: "0 0 10px 10px", zIndex: 500 }}
|
||||||
|
>
|
||||||
|
<small className="text-muted">
|
||||||
|
<i className="bi bi-geo-alt-fill text-primary me-1"></i>
|
||||||
|
{
|
||||||
|
items.filter((item) => item.latitude && item.longitude).length
|
||||||
|
}{" "}
|
||||||
|
items shown
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchResultsMap;
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||||
import { Item } from "../types";
|
import { Item } from "../types";
|
||||||
import { itemAPI } from "../services/api";
|
import { itemAPI } from "../services/api";
|
||||||
import ItemCard from "../components/ItemCard";
|
import ItemCard from "../components/ItemCard";
|
||||||
|
import SearchResultsMap from "../components/SearchResultsMap";
|
||||||
|
|
||||||
const ItemList: React.FC = () => {
|
const ItemList: React.FC = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [items, setItems] = useState<Item[]>([]);
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'list' | 'map'>('list');
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
search: searchParams.get("search") || "",
|
search: searchParams.get("search") || "",
|
||||||
city: searchParams.get("city") || "",
|
city: searchParams.get("city") || "",
|
||||||
@@ -58,6 +61,16 @@ const ItemList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleItemSelect = (item: Item) => {
|
||||||
|
navigate(`/items/${item.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSearchLocationString = () => {
|
||||||
|
if (filters.city) return filters.city;
|
||||||
|
if (filters.zipCode) return filters.zipCode;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="container mt-5">
|
<div className="container mt-5">
|
||||||
@@ -81,23 +94,63 @@ const ItemList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mt-4">
|
<div className="container-fluid mt-4" style={{ maxWidth: '1800px' }}>
|
||||||
<h1>Browse Items</h1>
|
<div className="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4 gap-3">
|
||||||
|
<div>
|
||||||
<div className="mb-4">
|
<h1 className="mb-1">Browse Items</h1>
|
||||||
<span className="text-muted">{items.length} items found</span>
|
<span className="text-muted">{items.length} items found</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="btn-group" role="group" aria-label="View toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-outline-secondary ${viewMode === 'list' ? 'active' : ''}`}
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-list-ul me-1 me-md-2"></i>
|
||||||
|
<span className="d-none d-md-inline">List</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-outline-secondary ${viewMode === 'map' ? 'active' : ''}`}
|
||||||
|
onClick={() => setViewMode('map')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-geo-alt-fill me-1 me-md-2"></i>
|
||||||
|
<span className="d-none d-md-inline">Map</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-center text-muted">No items available for rent.</p>
|
<div className="text-center py-5">
|
||||||
) : (
|
<i className="bi bi-search text-muted mb-3" style={{ fontSize: '4rem' }}></i>
|
||||||
|
<h3 className="text-muted">No items found</h3>
|
||||||
|
<p className="text-muted">
|
||||||
|
Try adjusting your search criteria or browse all available items.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : viewMode === 'list' ? (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.id} className="col-md-6 col-lg-4 mb-4">
|
<div key={item.id} className="col-md-6 col-lg-4 col-xl-3 mb-4">
|
||||||
<ItemCard item={item} variant="standard" />
|
<ItemCard item={item} variant="standard" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mb-4">
|
||||||
|
<SearchResultsMap
|
||||||
|
items={items}
|
||||||
|
searchLocation={getSearchLocationString()}
|
||||||
|
onItemSelect={handleItemSelect}
|
||||||
|
style={{
|
||||||
|
height: window.innerWidth < 768 ? '60vh' : '70vh',
|
||||||
|
minHeight: window.innerWidth < 768 ? '400px' : '500px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user