From 688f5ac8d6762969e13fb42584786bce2228d2c3 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:41:05 -0400 Subject: [PATCH] map search --- .../src/components/GoogleMapWithRadius.tsx | 15 +- frontend/src/components/ItemMarkerInfo.tsx | 98 ++++ frontend/src/components/SearchResultsMap.tsx | 432 ++++++++++++++++++ frontend/src/pages/ItemList.tsx | 71 ++- 4 files changed, 602 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/ItemMarkerInfo.tsx create mode 100644 frontend/src/components/SearchResultsMap.tsx diff --git a/frontend/src/components/GoogleMapWithRadius.tsx b/frontend/src/components/GoogleMapWithRadius.tsx index 164bffd..7c5163c 100644 --- a/frontend/src/components/GoogleMapWithRadius.tsx +++ b/frontend/src/components/GoogleMapWithRadius.tsx @@ -31,8 +31,9 @@ const GoogleMapWithRadius: React.FC = ({ // Destructure mapOptions to create stable references 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 mapId = process.env.REACT_APP_GOOGLE_MAPS_MAP_ID; // Refs for map container and instances const mapRef = useRef(null); @@ -60,17 +61,21 @@ const GoogleMapWithRadius: React.FC = ({ if (!mapRef.current) return; - // Create map - const map = new google.maps.Map(mapRef.current, { + // Create map configuration + const mapConfig: google.maps.MapOptions = { + mapId: mapId, center: mapCenter, zoom: zoom, + maxZoom: 15, // Prevent users from zooming too close zoomControl: true, mapTypeControl: false, scaleControl: true, streetViewControl: false, rotateControl: false, fullscreenControl: false, - }); + }; + + const map = new google.maps.Map(mapRef.current, mapConfig); mapInstanceRef.current = map; @@ -101,7 +106,7 @@ const GoogleMapWithRadius: React.FC = ({ } mapInstanceRef.current = null; }; - }, [apiKey, mapCenter, zoom]); + }, [apiKey, mapId, mapCenter, zoom]); // Update map center and circle when coordinates change useEffect(() => { diff --git a/frontend/src/components/ItemMarkerInfo.tsx b/frontend/src/components/ItemMarkerInfo.tsx new file mode 100644 index 0000000..22e6af4 --- /dev/null +++ b/frontend/src/components/ItemMarkerInfo.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { Item } from '../types'; + +interface ItemMarkerInfoProps { + item: Item; + onViewDetails?: () => void; +} + +const ItemMarkerInfo: React.FC = ({ 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 ( +
+
+ {item.images && item.images[0] ? ( + {item.name} + ) : ( +
+ +
+ )} + +
+
+ {item.name} +
+ +
+ + {getPriceDisplay()} + +
+ +
+ {getLocationDisplay()} +
+ + {item.description && ( +

+ {item.description} +

+ )} + +
+ +
+
+
+
+ ); +}; + +export default ItemMarkerInfo; \ No newline at end of file diff --git a/frontend/src/components/SearchResultsMap.tsx b/frontend/src/components/SearchResultsMap.tsx new file mode 100644 index 0000000..8c769f8 --- /dev/null +++ b/frontend/src/components/SearchResultsMap.tsx @@ -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 any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: NodeJS.Timeout; + return (...args: Parameters) => { + 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 = ({ + items, + searchLocation, + className, + style, + onItemSelect, +}) => { + const mapRef = useRef(null); + const mapInstanceRef = useRef(null); + const markersRef = useRef([]); + const infoWindowRef = useRef(null); + const mapInitializedRef = useRef(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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( + { + 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 ( +
+
+
+ +
No Location Data
+

+ None of the search results have location information available for + mapping. +

+
+
+
+ ); + } + + if (error) { + return ( +
+
+
+ +

{error}

+ +
+
+
+ ); + } + + return ( +
+ {isLoading && ( +
+
+
+ Loading map... +
+

Loading map...

+
+
+ )} + +
+ + {!isLoading && items.length > 0 && ( +
+ + + { + items.filter((item) => item.latitude && item.longitude).length + }{" "} + items shown + +
+ )} +
+ ); +}; + +export default SearchResultsMap; diff --git a/frontend/src/pages/ItemList.tsx b/frontend/src/pages/ItemList.tsx index cf3bcec..7c6e166 100644 --- a/frontend/src/pages/ItemList.tsx +++ b/frontend/src/pages/ItemList.tsx @@ -1,14 +1,17 @@ import React, { useState, useEffect } from "react"; -import { useSearchParams } from "react-router-dom"; +import { useSearchParams, useNavigate } from "react-router-dom"; import { Item } from "../types"; import { itemAPI } from "../services/api"; import ItemCard from "../components/ItemCard"; +import SearchResultsMap from "../components/SearchResultsMap"; const ItemList: React.FC = () => { const [searchParams] = useSearchParams(); + const navigate = useNavigate(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [viewMode, setViewMode] = useState<'list' | 'map'>('list'); const [filters, setFilters] = useState({ search: searchParams.get("search") || "", 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) { return (
@@ -81,23 +94,63 @@ const ItemList: React.FC = () => { } return ( -
-

Browse Items

- -
- {items.length} items found +
+
+
+

Browse Items

+ {items.length} items found +
+ + {items.length > 0 && ( +
+ + +
+ )}
{items.length === 0 ? ( -

No items available for rent.

- ) : ( +
+ +

No items found

+

+ Try adjusting your search criteria or browse all available items. +

+
+ ) : viewMode === 'list' ? (
{items.map((item) => ( -
+
))}
+ ) : ( +
+ +
)}
);